Skip to main content

max / makenotwork

4.9 KB · 146 lines History Blame Raw
1 //! Formula-driven pricing for end-user SyncKit subscriptions.
2 //!
3 //! No tier table — users pick any storage cap and the server quotes a price
4 //! computed from a fixed formula. Constants are kept in code (not the DB)
5 //! because there is exactly one formula in production today and a schema row
6 //! would just be indirection.
7
8 use crate::error::{AppError, Result};
9
10 /// Per-GB monthly cost in tenths of a cent. 8 ⇒ $0.008/GB/month
11 /// (0.8¢ = 8 tenths-of-a-cent), chosen to roughly cover Hetzner Object
12 /// Storage at the rack rate plus a thin egress buffer.
13 pub const PER_GB_TENTHS_OF_CENT_PER_MONTH: i64 = 8;
14
15 /// Minimum monthly or annual charge in cents. Covers Stripe's per-transaction
16 /// floor ($0.30 + 2.9%) without forcing the price up further for tiny accounts.
17 pub const MIN_CHARGE_CENTS: i64 = 200;
18
19 /// Annual price multiplier vs. monthly. ×10 = "two months free" structurally,
20 /// which is also where Stripe per-transaction fees disappear into the noise.
21 pub const ANNUAL_MULTIPLIER: i64 = 10;
22
23 /// Minimum and maximum storage caps the user may pick.
24 pub const MIN_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024; // 10 GiB
25 pub const MAX_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024 * 1024; // 10 TiB
26
27 /// Billing interval for a SyncKit app subscription.
28 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
29 pub enum SyncBillingInterval {
30 Monthly,
31 Annual,
32 }
33
34 impl SyncBillingInterval {
35 pub fn parse(s: &str) -> Result<Self> {
36 match s {
37 "monthly" => Ok(Self::Monthly),
38 "annual" => Ok(Self::Annual),
39 other => Err(AppError::BadRequest(format!(
40 "Invalid interval '{other}', expected 'monthly' or 'annual'"
41 ))),
42 }
43 }
44
45 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Monthly => "monthly",
48 Self::Annual => "annual",
49 }
50 }
51 }
52
53 fn cap_bytes_to_gb_ceil(cap_bytes: i64) -> i64 {
54 const GB: i64 = 1024 * 1024 * 1024;
55 (cap_bytes + GB - 1) / GB
56 }
57
58 /// Compute the price (in cents) for a given storage cap and interval.
59 ///
60 /// `cap_bytes` is validated against `MIN_CAP_BYTES` / `MAX_CAP_BYTES`.
61 pub fn quote_price_cents(cap_bytes: i64, interval: SyncBillingInterval) -> Result<i64> {
62 if cap_bytes < MIN_CAP_BYTES {
63 return Err(AppError::BadRequest(format!(
64 "Storage cap must be at least {} GiB",
65 MIN_CAP_BYTES / (1024 * 1024 * 1024)
66 )));
67 }
68 if cap_bytes > MAX_CAP_BYTES {
69 return Err(AppError::BadRequest(format!(
70 "Storage cap may not exceed {} GiB",
71 MAX_CAP_BYTES / (1024 * 1024 * 1024)
72 )));
73 }
74
75 let gb = cap_bytes_to_gb_ceil(cap_bytes);
76 // Storage-driven monthly cents, ceiling-divided so partial cents round up.
77 let storage_monthly_cents = gb * PER_GB_TENTHS_OF_CENT_PER_MONTH;
78 let storage_monthly_cents = (storage_monthly_cents + 9) / 10;
79 let monthly_cents = storage_monthly_cents.max(MIN_CHARGE_CENTS);
80
81 Ok(match interval {
82 SyncBillingInterval::Monthly => monthly_cents,
83 SyncBillingInterval::Annual => monthly_cents * ANNUAL_MULTIPLIER,
84 })
85 }
86
87 #[cfg(test)]
88 mod tests {
89 use super::*;
90
91 fn gb(n: i64) -> i64 {
92 n * 1024 * 1024 * 1024
93 }
94
95 #[test]
96 fn minimum_cap_hits_floor() {
97 let price = quote_price_cents(gb(10), SyncBillingInterval::Monthly).unwrap();
98 assert_eq!(price, MIN_CHARGE_CENTS);
99 }
100
101 #[test]
102 fn small_cap_uses_floor_until_breakeven() {
103 // 100 GB × $0.008 = $0.80 → below the $2 floor.
104 let price = quote_price_cents(gb(100), SyncBillingInterval::Monthly).unwrap();
105 assert_eq!(price, MIN_CHARGE_CENTS);
106 }
107
108 #[test]
109 fn breakeven_around_250gb() {
110 // 250 GB × $0.008 = $2.00 → equal to floor; storage formula starts taking over.
111 let price = quote_price_cents(gb(250), SyncBillingInterval::Monthly).unwrap();
112 assert_eq!(price, 200);
113 }
114
115 #[test]
116 fn large_cap_scales() {
117 // 1 TiB = 1024 GiB × $0.008 = $8.192 → 820 cents (ceil).
118 let price = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
119 assert_eq!(price, 820);
120 }
121
122 #[test]
123 fn annual_is_ten_times_monthly() {
124 let monthly = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
125 let annual = quote_price_cents(gb(1024), SyncBillingInterval::Annual).unwrap();
126 assert_eq!(annual, monthly * 10);
127 }
128
129 #[test]
130 fn below_minimum_cap_rejected() {
131 assert!(quote_price_cents(gb(5), SyncBillingInterval::Monthly).is_err());
132 }
133
134 #[test]
135 fn above_maximum_cap_rejected() {
136 assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err());
137 }
138
139 #[test]
140 fn interval_parse_round_trip() {
141 assert_eq!(SyncBillingInterval::parse("monthly").unwrap(), SyncBillingInterval::Monthly);
142 assert_eq!(SyncBillingInterval::parse("annual").unwrap(), SyncBillingInterval::Annual);
143 assert!(SyncBillingInterval::parse("weekly").is_err());
144 }
145 }
146