Skip to main content

max / makenotwork

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