//! Formula-driven pricing for end-user SyncKit subscriptions. //! //! No tier table, users pick any storage cap and the server quotes a price //! computed from a fixed formula. Constants are kept in code (not the DB) //! because there is exactly one formula in production today and a schema row //! would just be indirection. use crate::error::{AppError, Result}; /// Per-GB monthly cost in tenths of a cent. 8 ⇒ $0.008/GB/month /// (0.8¢ = 8 tenths-of-a-cent), chosen to roughly cover Hetzner Object /// Storage at the rack rate plus a thin egress buffer. pub const PER_GB_TENTHS_OF_CENT_PER_MONTH: i64 = 8; /// Minimum monthly or annual charge in cents. Covers Stripe's per-transaction /// floor ($0.30 + 2.9%) without forcing the price up further for tiny accounts. pub const MIN_CHARGE_CENTS: i64 = 200; /// Annual price multiplier vs. monthly. ×10 = "two months free" structurally, /// which is also where Stripe per-transaction fees disappear into the noise. pub const ANNUAL_MULTIPLIER: i64 = 10; /// Minimum and maximum storage caps the user may pick. /// /// The floor is where `MIN_CHARGE_CENTS` stops binding: 250 GiB x 0.8c is /// exactly $2.00, so every cap below it costs the same $2.00 for less storage /// and is strictly dominated. Offering that range asks the user to choose /// against their own interest. Should the floor or the rate move, this constant /// moves with them. pub const MIN_CAP_BYTES: i64 = 250 * 1024 * 1024 * 1024; // 250 GiB pub const MAX_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024 * 1024; // 10 TiB /// Billing interval for a SyncKit app subscription. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SyncBillingInterval { Monthly, Annual, } impl SyncBillingInterval { pub fn parse(s: &str) -> Result { match s { "monthly" => Ok(Self::Monthly), "annual" => Ok(Self::Annual), other => Err(AppError::BadRequest(format!( "Invalid interval '{other}', expected 'monthly' or 'annual'" ))), } } pub fn as_str(self) -> &'static str { match self { Self::Monthly => "monthly", Self::Annual => "annual", } } } fn cap_bytes_to_gb_ceil(cap_bytes: i64) -> i64 { const GB: i64 = 1024 * 1024 * 1024; (cap_bytes + GB - 1) / GB } /// Compute the price (in cents) for a given storage cap and interval. /// /// `cap_bytes` is validated against `MIN_CAP_BYTES` / `MAX_CAP_BYTES`. pub fn quote_price_cents(cap_bytes: i64, interval: SyncBillingInterval) -> Result { if cap_bytes < MIN_CAP_BYTES { return Err(AppError::BadRequest(format!( "Storage cap must be at least {} GiB", MIN_CAP_BYTES / (1024 * 1024 * 1024) ))); } if cap_bytes > MAX_CAP_BYTES { return Err(AppError::BadRequest(format!( "Storage cap may not exceed {} GiB", MAX_CAP_BYTES / (1024 * 1024 * 1024) ))); } let gb = cap_bytes_to_gb_ceil(cap_bytes); // Storage-driven monthly cents, ceiling-divided so partial cents round up. let storage_monthly_cents = gb * PER_GB_TENTHS_OF_CENT_PER_MONTH; let storage_monthly_cents = (storage_monthly_cents + 9) / 10; let monthly_cents = storage_monthly_cents.max(MIN_CHARGE_CENTS); Ok(match interval { SyncBillingInterval::Monthly => monthly_cents, SyncBillingInterval::Annual => monthly_cents * ANNUAL_MULTIPLIER, }) } #[cfg(test)] mod tests { use super::*; fn gb(n: i64) -> i64 { n * 1024 * 1024 * 1024 } #[test] fn minimum_cap_hits_floor() { // 250 GiB x $0.008 = $2.00, exactly the floor: the cheapest cap on // offer is also the last one the floor sets the price for. let price = quote_price_cents(MIN_CAP_BYTES, SyncBillingInterval::Monthly).unwrap(); assert_eq!(price, MIN_CHARGE_CENTS); } #[test] fn no_cap_on_offer_is_dominated_by_a_larger_one() { // Why MIN_CAP_BYTES sits at 250 GiB rather than lower: below the // break-even the floor binds, so a smaller cap buys less storage for // the same money. Nothing quotable may be priced at the floor except // the minimum itself. If the rate or the floor moves, this fails and // MIN_CAP_BYTES is what needs recomputing. for cap_gib in [251, 300, 512, 1024] { let price = quote_price_cents(gb(cap_gib), SyncBillingInterval::Monthly).unwrap(); assert!( price > MIN_CHARGE_CENTS, "{cap_gib} GiB still quotes at the floor, so caps below it are dominated" ); } } #[test] fn large_cap_scales() { // 1 TiB = 1024 GiB × $0.008 = $8.192 → 820 cents (ceil). let price = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap(); assert_eq!(price, 820); } #[test] fn annual_is_ten_times_monthly() { let monthly = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap(); let annual = quote_price_cents(gb(1024), SyncBillingInterval::Annual).unwrap(); assert_eq!(annual, monthly * 10); } #[test] fn below_minimum_cap_rejected() { assert!(quote_price_cents(gb(200), SyncBillingInterval::Monthly).is_err()); } #[test] fn above_maximum_cap_rejected() { assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err()); } #[test] fn cap_bounds_are_inclusive_at_both_ends() { // `gb(20_000)` above is far enough over the ceiling that an off-by-one // on either bound, or a `MAX_CAP_BYTES` that means something other than // 10 TiB, still rejects it. These sit on the two edges instead. assert_eq!(MAX_CAP_BYTES, gb(10 * 1024), "10 TiB, in bytes"); assert_eq!(MIN_CAP_BYTES, gb(250), "250 GiB, in bytes"); assert!(quote_price_cents(MAX_CAP_BYTES, SyncBillingInterval::Monthly).is_ok()); assert!(quote_price_cents(MAX_CAP_BYTES + 1, SyncBillingInterval::Monthly).is_err()); assert!(quote_price_cents(MIN_CAP_BYTES, SyncBillingInterval::Monthly).is_ok()); assert!(quote_price_cents(MIN_CAP_BYTES - 1, SyncBillingInterval::Monthly).is_err()); } #[test] fn interval_as_str_is_the_wire_form_parse_reads_back() { assert_eq!(SyncBillingInterval::Monthly.as_str(), "monthly"); assert_eq!(SyncBillingInterval::Annual.as_str(), "annual"); for interval in [SyncBillingInterval::Monthly, SyncBillingInterval::Annual] { assert_eq!( SyncBillingInterval::parse(interval.as_str()).unwrap(), interval ); } } #[test] fn interval_parse_round_trip() { assert_eq!( SyncBillingInterval::parse("monthly").unwrap(), SyncBillingInterval::Monthly ); assert_eq!( SyncBillingInterval::parse("annual").unwrap(), SyncBillingInterval::Annual ); assert!(SyncBillingInterval::parse("weekly").is_err()); } }