Skip to main content

max / makenotwork

7.1 KB · 188 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 ///
25 /// The floor is where `MIN_CHARGE_CENTS` stops binding: 250 GiB x 0.8c is
26 /// exactly $2.00, so every cap below it costs the same $2.00 for less storage
27 /// and is strictly dominated. Offering that range asks the user to choose
28 /// against their own interest. Raised from 10 GiB on 2026-08-21, when prod
29 /// carried no SyncKit subscriptions at all, so nothing needed grandfathering.
30 /// Should the floor or the rate move, this constant moves with them.
31 pub const MIN_CAP_BYTES: i64 = 250 * 1024 * 1024 * 1024; // 250 GiB
32 pub const MAX_CAP_BYTES: i64 = 10 * 1024 * 1024 * 1024 * 1024; // 10 TiB
33
34 /// Billing interval for a SyncKit app subscription.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub enum SyncBillingInterval {
37 Monthly,
38 Annual,
39 }
40
41 impl SyncBillingInterval {
42 pub fn parse(s: &str) -> Result<Self> {
43 match s {
44 "monthly" => Ok(Self::Monthly),
45 "annual" => Ok(Self::Annual),
46 other => Err(AppError::BadRequest(format!(
47 "Invalid interval '{other}', expected 'monthly' or 'annual'"
48 ))),
49 }
50 }
51
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Self::Monthly => "monthly",
55 Self::Annual => "annual",
56 }
57 }
58 }
59
60 fn cap_bytes_to_gb_ceil(cap_bytes: i64) -> i64 {
61 const GB: i64 = 1024 * 1024 * 1024;
62 (cap_bytes + GB - 1) / GB
63 }
64
65 /// Compute the price (in cents) for a given storage cap and interval.
66 ///
67 /// `cap_bytes` is validated against `MIN_CAP_BYTES` / `MAX_CAP_BYTES`.
68 pub fn quote_price_cents(cap_bytes: i64, interval: SyncBillingInterval) -> Result<i64> {
69 if cap_bytes < MIN_CAP_BYTES {
70 return Err(AppError::BadRequest(format!(
71 "Storage cap must be at least {} GiB",
72 MIN_CAP_BYTES / (1024 * 1024 * 1024)
73 )));
74 }
75 if cap_bytes > MAX_CAP_BYTES {
76 return Err(AppError::BadRequest(format!(
77 "Storage cap may not exceed {} GiB",
78 MAX_CAP_BYTES / (1024 * 1024 * 1024)
79 )));
80 }
81
82 let gb = cap_bytes_to_gb_ceil(cap_bytes);
83 // Storage-driven monthly cents, ceiling-divided so partial cents round up.
84 let storage_monthly_cents = gb * PER_GB_TENTHS_OF_CENT_PER_MONTH;
85 let storage_monthly_cents = (storage_monthly_cents + 9) / 10;
86 let monthly_cents = storage_monthly_cents.max(MIN_CHARGE_CENTS);
87
88 Ok(match interval {
89 SyncBillingInterval::Monthly => monthly_cents,
90 SyncBillingInterval::Annual => monthly_cents * ANNUAL_MULTIPLIER,
91 })
92 }
93
94 #[cfg(test)]
95 mod tests {
96 use super::*;
97
98 fn gb(n: i64) -> i64 {
99 n * 1024 * 1024 * 1024
100 }
101
102 #[test]
103 fn minimum_cap_hits_floor() {
104 // 250 GiB x $0.008 = $2.00, exactly the floor: the cheapest cap on
105 // offer is also the last one the floor sets the price for.
106 let price = quote_price_cents(MIN_CAP_BYTES, SyncBillingInterval::Monthly).unwrap();
107 assert_eq!(price, MIN_CHARGE_CENTS);
108 }
109
110 #[test]
111 fn no_cap_on_offer_is_dominated_by_a_larger_one() {
112 // Why MIN_CAP_BYTES sits at 250 GiB rather than lower: below the
113 // break-even the floor binds, so a smaller cap buys less storage for
114 // the same money. Nothing quotable may be priced at the floor except
115 // the minimum itself. If the rate or the floor moves, this fails and
116 // MIN_CAP_BYTES is what needs recomputing.
117 for cap_gib in [251, 300, 512, 1024] {
118 let price = quote_price_cents(gb(cap_gib), SyncBillingInterval::Monthly).unwrap();
119 assert!(
120 price > MIN_CHARGE_CENTS,
121 "{cap_gib} GiB still quotes at the floor, so caps below it are dominated"
122 );
123 }
124 }
125
126 #[test]
127 fn large_cap_scales() {
128 // 1 TiB = 1024 GiB × $0.008 = $8.192 → 820 cents (ceil).
129 let price = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
130 assert_eq!(price, 820);
131 }
132
133 #[test]
134 fn annual_is_ten_times_monthly() {
135 let monthly = quote_price_cents(gb(1024), SyncBillingInterval::Monthly).unwrap();
136 let annual = quote_price_cents(gb(1024), SyncBillingInterval::Annual).unwrap();
137 assert_eq!(annual, monthly * 10);
138 }
139
140 #[test]
141 fn below_minimum_cap_rejected() {
142 assert!(quote_price_cents(gb(200), SyncBillingInterval::Monthly).is_err());
143 }
144
145 #[test]
146 fn above_maximum_cap_rejected() {
147 assert!(quote_price_cents(gb(20_000), SyncBillingInterval::Monthly).is_err());
148 }
149
150 #[test]
151 fn cap_bounds_are_inclusive_at_both_ends() {
152 // `gb(20_000)` above is far enough over the ceiling that an off-by-one
153 // on either bound, or a `MAX_CAP_BYTES` that means something other than
154 // 10 TiB, still rejects it. These sit on the two edges instead.
155 assert_eq!(MAX_CAP_BYTES, gb(10 * 1024), "10 TiB, in bytes");
156 assert_eq!(MIN_CAP_BYTES, gb(250), "250 GiB, in bytes");
157 assert!(quote_price_cents(MAX_CAP_BYTES, SyncBillingInterval::Monthly).is_ok());
158 assert!(quote_price_cents(MAX_CAP_BYTES + 1, SyncBillingInterval::Monthly).is_err());
159 assert!(quote_price_cents(MIN_CAP_BYTES, SyncBillingInterval::Monthly).is_ok());
160 assert!(quote_price_cents(MIN_CAP_BYTES - 1, SyncBillingInterval::Monthly).is_err());
161 }
162
163 #[test]
164 fn interval_as_str_is_the_wire_form_parse_reads_back() {
165 assert_eq!(SyncBillingInterval::Monthly.as_str(), "monthly");
166 assert_eq!(SyncBillingInterval::Annual.as_str(), "annual");
167 for interval in [SyncBillingInterval::Monthly, SyncBillingInterval::Annual] {
168 assert_eq!(
169 SyncBillingInterval::parse(interval.as_str()).unwrap(),
170 interval
171 );
172 }
173 }
174
175 #[test]
176 fn interval_parse_round_trip() {
177 assert_eq!(
178 SyncBillingInterval::parse("monthly").unwrap(),
179 SyncBillingInterval::Monthly
180 );
181 assert_eq!(
182 SyncBillingInterval::parse("annual").unwrap(),
183 SyncBillingInterval::Annual
184 );
185 assert!(SyncBillingInterval::parse("weekly").is_err());
186 }
187 }
188