//! 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. pub const MIN_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; // 10 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() { let price = quote_price_cents(gb(10), SyncBillingInterval::Monthly).unwrap(); assert_eq!(price, MIN_CHARGE_CENTS); } #[test] fn small_cap_uses_floor_until_breakeven() { // 100 GB × $0.008 = $0.80 → below the $2 floor. let price = quote_price_cents(gb(100), SyncBillingInterval::Monthly).unwrap(); assert_eq!(price, MIN_CHARGE_CENTS); } #[test] fn breakeven_around_250gb() { // 250 GB × $0.008 = $2.00 → equal to floor; storage formula starts taking over. let price = quote_price_cents(gb(250), SyncBillingInterval::Monthly).unwrap(); assert_eq!(price, 200); } #[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(5), SyncBillingInterval::Monthly).is_err()); } #[test] fn above_maximum_cap_rejected() { assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err()); } #[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()); } }