Skip to main content

max / makenotwork

18.2 KB · 426 lines History Blame Raw
1 //! Tier prices and storage envelopes pulled from `assumptions.toml` at startup.
2 //!
3 //! Templates referencing these via `{{ tier_prices.basic_std }}` etc. stay in
4 //! sync with the docengine substitution system, both read from the same toml.
5 //! A price change is a one-line edit to assumptions.toml + a server restart.
6 //!
7 //! Missing or wrong-typed keys panic at startup (same pattern as
8 //! `Assumptions::validate` failure in main.rs). Production never serves with a
9 //! half-loaded `TierPrices`.
10 //!
11 //! `TierPrices::install_global` writes the loaded instance into a process-wide
12 //! `OnceLock` so `CreatorTier` accessors (`price_cents`, `max_file_bytes`,
13 //! `max_storage_bytes`) can read from it without threading state through every
14 //! caller. `main.rs` calls it before the server binds; tests call
15 //! `install_test_default` before touching CreatorTier.
16
17 use std::sync::OnceLock;
18
19 use mnw_assumptions::{Assumptions, LookupValue};
20
21 use crate::db::CreatorTier;
22
23 static GLOBAL: OnceLock<TierPrices> = OnceLock::new();
24
25 #[derive(Clone, Debug, Default)]
26 pub struct TierPrices {
27 // Standard monthly (post-founder sticker rates).
28 pub basic_std: i32,
29 pub small_files_std: i32,
30 pub big_files_std: i32,
31 pub everything_std: i32,
32 // Founder monthly (50% of standard, locked for life when window closes).
33 pub basic_founder: i32,
34 pub small_files_founder: i32,
35 pub big_files_founder: i32,
36 pub everything_founder: i32,
37 // Standard annual (monthly × 12 × annual_discount.multiplier, rounded).
38 pub annual_basic_std: i32,
39 pub annual_small_files_std: i32,
40 pub annual_big_files_std: i32,
41 pub annual_everything_std: i32,
42 // Founder annual.
43 pub annual_basic_founder: i32,
44 pub annual_small_files_founder: i32,
45 pub annual_big_files_founder: i32,
46 pub annual_everything_founder: i32,
47 // Per-file caps and total storage caps, as display strings ("10MB", "50GB").
48 pub basic_per_file: String,
49 pub small_files_per_file: String,
50 pub big_files_per_file: String,
51 pub everything_per_file: String,
52 pub basic_total: String,
53 pub small_files_total: String,
54 pub big_files_total: String,
55 pub everything_total: String,
56 // Same envelopes as machine-readable byte counts (from [tier_bytes]).
57 // The docengine validator asserts these parse back to the display strings
58 // above, so drift between the two forms fails at boot.
59 pub basic_per_file_bytes: i64,
60 pub small_files_per_file_bytes: i64,
61 pub big_files_per_file_bytes: i64,
62 pub everything_per_file_bytes: i64,
63 pub basic_total_bytes: i64,
64 pub small_files_total_bytes: i64,
65 pub big_files_total_bytes: i64,
66 pub everything_total_bytes: i64,
67 // Founder cohort cap, display string with thousands separator ("1,000").
68 pub cohort_cap_display: String,
69 }
70
71 impl TierPrices {
72 pub fn from_assumptions(a: &Assumptions) -> Self {
73 Self {
74 basic_std: int_at(a, "tiers.standard.basic"),
75 small_files_std: int_at(a, "tiers.standard.small_files"),
76 big_files_std: int_at(a, "tiers.standard.big_files"),
77 everything_std: int_at(a, "tiers.standard.everything"),
78 basic_founder: int_at(a, "tiers.founding.basic"),
79 small_files_founder: int_at(a, "tiers.founding.small_files"),
80 big_files_founder: int_at(a, "tiers.founding.big_files"),
81 everything_founder: int_at(a, "tiers.founding.everything"),
82 annual_basic_std: int_at(a, "derived.annual_standard_basic"),
83 annual_small_files_std: int_at(a, "derived.annual_standard_small_files"),
84 annual_big_files_std: int_at(a, "derived.annual_standard_big_files"),
85 annual_everything_std: int_at(a, "derived.annual_standard_everything"),
86 annual_basic_founder: int_at(a, "derived.annual_founding_basic"),
87 annual_small_files_founder: int_at(a, "derived.annual_founding_small_files"),
88 annual_big_files_founder: int_at(a, "derived.annual_founding_big_files"),
89 annual_everything_founder: int_at(a, "derived.annual_founding_everything"),
90 basic_per_file: str_at(a, "tier_limits.basic_per_file"),
91 small_files_per_file: str_at(a, "tier_limits.small_files_per_file"),
92 big_files_per_file: str_at(a, "tier_limits.big_files_per_file"),
93 everything_per_file: str_at(a, "tier_limits.everything_per_file"),
94 basic_total: str_at(a, "tier_limits.basic_total"),
95 small_files_total: str_at(a, "tier_limits.small_files_total"),
96 big_files_total: str_at(a, "tier_limits.big_files_total"),
97 everything_total: str_at(a, "tier_limits.everything_total"),
98 basic_per_file_bytes: bytes_at(a, "tier_bytes.basic_per_file"),
99 small_files_per_file_bytes: bytes_at(a, "tier_bytes.small_files_per_file"),
100 big_files_per_file_bytes: bytes_at(a, "tier_bytes.big_files_per_file"),
101 everything_per_file_bytes: bytes_at(a, "tier_bytes.everything_per_file"),
102 basic_total_bytes: bytes_at(a, "tier_bytes.basic_total"),
103 small_files_total_bytes: bytes_at(a, "tier_bytes.small_files_total"),
104 big_files_total_bytes: bytes_at(a, "tier_bytes.big_files_total"),
105 everything_total_bytes: bytes_at(a, "tier_bytes.everything_total"),
106 cohort_cap_display: str_at(a, "cohort.cap_display"),
107 }
108 }
109
110 /// Monthly standard price in cents for the given tier. Backs
111 /// `CreatorTier::price_cents`, see the OnceLock note at the top of this
112 /// module.
113 pub fn price_cents_for(&self, tier: CreatorTier) -> i32 {
114 (match tier {
115 CreatorTier::Basic => self.basic_std,
116 CreatorTier::SmallFiles => self.small_files_std,
117 CreatorTier::BigFiles => self.big_files_std,
118 CreatorTier::Everything => self.everything_std,
119 }) * 100
120 }
121
122 /// Per-upload byte cap for the given tier.
123 pub fn max_file_bytes_for(&self, tier: CreatorTier) -> i64 {
124 match tier {
125 CreatorTier::Basic => self.basic_per_file_bytes,
126 CreatorTier::SmallFiles => self.small_files_per_file_bytes,
127 CreatorTier::BigFiles => self.big_files_per_file_bytes,
128 CreatorTier::Everything => self.everything_per_file_bytes,
129 }
130 }
131
132 /// Total storage byte cap for the given tier.
133 pub fn max_storage_bytes_for(&self, tier: CreatorTier) -> i64 {
134 match tier {
135 CreatorTier::Basic => self.basic_total_bytes,
136 CreatorTier::SmallFiles => self.small_files_total_bytes,
137 CreatorTier::BigFiles => self.big_files_total_bytes,
138 CreatorTier::Everything => self.everything_total_bytes,
139 }
140 }
141
142 /// Install this instance as the process-wide `CreatorTier` config source.
143 /// Called from `main.rs` once, before any request handling. Subsequent
144 /// calls are ignored (OnceLock semantics); production installs exactly
145 /// once.
146 pub fn install_global(self) {
147 if GLOBAL.set(self).is_err() {
148 // Not fatal (the first install is the live one and prices are read
149 // from it either way), but in production this is called exactly
150 // once, so a second call means two config sources exist and the
151 // second one is being ignored.
152 tracing::warn!(
153 "TierPrices::install_global called more than once; keeping the first install"
154 );
155 }
156 }
157
158 /// Read the installed global. Panics if `install_global` hasn't been
159 /// called, same failure mode as boot-time toml validation.
160 pub fn global() -> &'static TierPrices {
161 GLOBAL.get().expect(
162 "TierPrices::install_global was not called before CreatorTier accessor use, \
163 call install_global in main.rs or TierPrices::install_test_default in a test",
164 )
165 }
166
167 /// Install the canonical fixture into the global slot for test use.
168 /// Idempotent; safe to call from multiple tests concurrently. Not
169 /// cfg-gated so it is available to integration-test harnesses regardless
170 /// of whether the gate build is debug or release; never called in prod
171 /// (main.rs installs from the live assumptions instead).
172 pub fn install_test_default() {
173 // If already installed (either by an earlier test or by an integration
174 // harness), leave it, the values are stable across tests.
175 if GLOBAL.get().is_some() {
176 return;
177 }
178 // Path is relative to the crate root at test time.
179 let a = Assumptions::load("docs/business/assumptions.toml")
180 .expect("test setup: load canonical assumptions.toml");
181 let tp = TierPrices::from_assumptions(&a);
182 // Ignored deliberately: concurrent tests race to install here and the
183 // values are identical, so losing the race is the documented outcome
184 // rather than a failure.
185 let _ = GLOBAL.set(tp);
186 }
187 }
188
189 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
190 #[derive(Clone, Debug)]
191 pub struct TierCard {
192 pub key: &'static str,
193 pub label: &'static str,
194 pub storage: String,
195 pub founder_monthly: i32,
196 pub standard_monthly: i32,
197 pub founder_annual: i32,
198 pub standard_annual: i32,
199 }
200
201 impl TierPrices {
202 /// Build the four tier cards the dashboard renders. Order matters
203 /// (Basic, Small Files, Big Files, Everything), it's the canonical
204 /// presentation order.
205 pub fn cards(&self) -> Vec<TierCard> {
206 vec![
207 TierCard {
208 key: "basic",
209 label: "Basic",
210 storage: format!("{}, {}/file", self.basic_total, self.basic_per_file),
211 founder_monthly: self.basic_founder,
212 standard_monthly: self.basic_std,
213 founder_annual: self.annual_basic_founder,
214 standard_annual: self.annual_basic_std,
215 },
216 TierCard {
217 key: "small_files",
218 label: "Small Files",
219 storage: format!(
220 "{}, {}/file",
221 self.small_files_total, self.small_files_per_file
222 ),
223 founder_monthly: self.small_files_founder,
224 standard_monthly: self.small_files_std,
225 founder_annual: self.annual_small_files_founder,
226 standard_annual: self.annual_small_files_std,
227 },
228 TierCard {
229 key: "big_files",
230 label: "Big Files",
231 storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file),
232 founder_monthly: self.big_files_founder,
233 standard_monthly: self.big_files_std,
234 founder_annual: self.annual_big_files_founder,
235 standard_annual: self.annual_big_files_std,
236 },
237 TierCard {
238 key: "everything",
239 label: "Everything",
240 storage: format!(
241 "{}, {}/file, all features",
242 self.everything_total, self.everything_per_file
243 ),
244 founder_monthly: self.everything_founder,
245 standard_monthly: self.everything_std,
246 founder_annual: self.annual_everything_founder,
247 standard_annual: self.annual_everything_std,
248 },
249 ]
250 }
251 }
252
253 /// Operator-edited runway figures, loaded once at startup. The
254 /// live paying-creator counts come from the DB at request time and
255 /// are NOT in this struct, see `db::creator_tiers::count_active_paying`
256 /// and `count_trialing_or_grace`.
257 ///
258 /// `quarters` is the cash-runway bucket in whole quarters (rounded down).
259 /// A value of `0` means "not yet published" and the template should
260 /// suppress the line rather than render "0 quarters".
261 ///
262 /// `last_updated_iso` is the date the operator last refreshed the figure,
263 /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated"
264 /// stamp on the disclosure surface.
265 #[derive(Clone, Debug, Default)]
266 pub struct RunwayConfig {
267 pub quarters: i32,
268 pub last_updated_iso: String,
269 }
270
271 impl RunwayConfig {
272 pub fn from_assumptions(a: &Assumptions) -> Self {
273 Self {
274 quarters: int_at(a, "runway.quarters"),
275 last_updated_iso: str_at(a, "runway.last_updated_iso"),
276 }
277 }
278 /// True iff the operator has published a runway figure. Suppress the
279 /// "X quarters at current burn" line when this is false.
280 pub fn is_published(&self) -> bool {
281 self.quarters > 0
282 }
283 }
284
285 fn int_at(a: &Assumptions, key: &str) -> i32 {
286 match a.get(key) {
287 Some(LookupValue::Int(n)) => {
288 i32::try_from(*n).unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32"))
289 }
290 Some(LookupValue::Float(x)) => x.round() as i32,
291 other => panic!("expected integer at {key}, got {other:?}"),
292 }
293 }
294
295 /// Byte counts are i64 (Basic total = 10GB fits, Everything total = 500GB fits;
296 /// hitting i32 max is a ~2 GB tier which we'd never allow, but keep the room).
297 fn bytes_at(a: &Assumptions, key: &str) -> i64 {
298 match a.get(key) {
299 Some(LookupValue::Int(n)) => *n,
300 other => panic!("expected integer at {key}, got {other:?}"),
301 }
302 }
303
304 fn str_at(a: &Assumptions, key: &str) -> String {
305 match a.get(key) {
306 Some(LookupValue::String(s)) => s.clone(),
307 other => panic!("expected string at {key}, got {other:?}"),
308 }
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314
315 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
316
317 #[test]
318 fn runway_config_loads_from_canonical_assumptions() {
319 // The presence of the [runway] block is the only enforced thing,
320 // the values inside are operator-edited. We pin the keys so a
321 // future toml edit that renames `quarters` or `last_updated_iso`
322 // is caught at PR time, not at boot.
323 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
324 let r = RunwayConfig::from_assumptions(&a);
325 assert!(r.quarters >= 0, "quarters must be a non-negative integer");
326 assert!(
327 !r.last_updated_iso.is_empty(),
328 "last_updated_iso must be set"
329 );
330 // ISO 8601 date format: YYYY-MM-DD.
331 assert_eq!(r.last_updated_iso.len(), 10);
332 assert!(r.last_updated_iso.chars().nth(4) == Some('-'));
333 assert!(r.last_updated_iso.chars().nth(7) == Some('-'));
334 }
335
336 #[test]
337 fn runway_config_is_published_only_when_quarters_nonzero() {
338 // The disclosure template hides the cash-runway bullet when this
339 // returns false, so a freshly-deployed instance with quarters=0
340 // doesn't display "0 quarters at current burn", which would be
341 // both wrong and alarming.
342 let r = RunwayConfig {
343 quarters: 0,
344 last_updated_iso: "2026-06-03".into(),
345 };
346 assert!(!r.is_published());
347 let r = RunwayConfig {
348 quarters: 4,
349 last_updated_iso: "2026-06-03".into(),
350 };
351 assert!(r.is_published());
352 }
353
354 #[test]
355 fn from_canonical_assumptions_populates_every_field() {
356 // Guards every key TierPrices reads. If a future toml edit removes
357 // one of these or flips its type, the panic in `from_assumptions`
358 // fires at startup; this test catches it at PR time instead. All
359 // assertions are *structural* invariants, the literal numbers
360 // live in the toml itself.
361 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
362 let p = TierPrices::from_assumptions(&a);
363
364 // Every price/annual field must be positive.
365 for (name, v) in [
366 ("basic_std", p.basic_std),
367 ("small_files_std", p.small_files_std),
368 ("big_files_std", p.big_files_std),
369 ("everything_std", p.everything_std),
370 ("basic_founder", p.basic_founder),
371 ("small_files_founder", p.small_files_founder),
372 ("big_files_founder", p.big_files_founder),
373 ("everything_founder", p.everything_founder),
374 ("annual_basic_std", p.annual_basic_std),
375 ("annual_small_files_std", p.annual_small_files_std),
376 ("annual_big_files_std", p.annual_big_files_std),
377 ("annual_everything_std", p.annual_everything_std),
378 ("annual_basic_founder", p.annual_basic_founder),
379 ("annual_small_files_founder", p.annual_small_files_founder),
380 ("annual_big_files_founder", p.annual_big_files_founder),
381 ("annual_everything_founder", p.annual_everything_founder),
382 ] {
383 assert!(v > 0, "{name} = {v} must be positive");
384 }
385
386 // Founder is exactly 50% of standard at every tier (policy invariant
387 //, enforced by docengine's `founding ≤ standard` and by pricing
388 // policy in `pricing.md`).
389 assert_eq!(
390 p.basic_founder * 2,
391 p.basic_std,
392 "founder must be 50% of standard"
393 );
394 assert_eq!(p.small_files_founder * 2, p.small_files_std);
395 assert_eq!(p.big_files_founder * 2, p.big_files_std);
396 assert_eq!(p.everything_founder * 2, p.everything_std);
397
398 // Standard is monotone across the tier ladder.
399 assert!(p.basic_std < p.small_files_std);
400 assert!(p.small_files_std < p.big_files_std);
401 assert!(p.big_files_std < p.everything_std);
402
403 // Envelope byte-counts are positive and (for storage totals) non-decreasing.
404 assert!(p.basic_per_file_bytes > 0);
405 assert!(p.basic_total_bytes > 0);
406 assert!(p.basic_total_bytes <= p.small_files_total_bytes);
407 assert!(p.small_files_total_bytes <= p.big_files_total_bytes);
408 assert_eq!(p.big_files_total_bytes, p.everything_total_bytes);
409 assert_eq!(p.big_files_per_file_bytes, p.everything_per_file_bytes);
410
411 // Display strings are non-empty.
412 assert!(!p.basic_per_file.is_empty());
413 assert!(!p.everything_total.is_empty());
414 assert!(!p.cohort_cap_display.is_empty());
415
416 // Cards iteration produces the four canonical rows in canonical order.
417 let cards = p.cards();
418 assert_eq!(cards.len(), 4);
419 assert_eq!(cards[0].key, "basic");
420 assert_eq!(cards[1].key, "small_files");
421 assert_eq!(cards[2].key, "big_files");
422 assert_eq!(cards[3].key, "everything");
423 assert_eq!(cards[1].standard_monthly, p.small_files_std);
424 }
425 }
426