Skip to main content

max / makenotwork

20.9 KB · 494 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 a second time; the new table is ignored and \
154 the first install stays in force"
155 );
156 }
157 }
158
159 /// Read the installed global. Panics if `install_global` hasn't been
160 /// called, same failure mode as boot-time toml validation.
161 pub fn global() -> &'static TierPrices {
162 GLOBAL.get().expect(
163 "TierPrices::install_global was not called before CreatorTier accessor use, \
164 call install_global in main.rs or TierPrices::install_test_default in a test",
165 )
166 }
167
168 /// Install the canonical fixture into the global slot for test use.
169 /// Idempotent; safe to call from multiple tests concurrently. Not
170 /// cfg-gated so it is available to integration-test harnesses regardless
171 /// of whether the gate build is debug or release; never called in prod
172 /// (main.rs installs from the live assumptions instead).
173 pub fn install_test_default() {
174 // If already installed (either by an earlier test or by an integration
175 // harness), leave it, the values are stable across tests.
176 if GLOBAL.get().is_some() {
177 return;
178 }
179 // Path is relative to the crate root at test time.
180 let a = Assumptions::load("docs/business/assumptions.toml")
181 .expect("test setup: load canonical assumptions.toml");
182 // Through `install_global` rather than around it: losing the race is
183 // the documented outcome in both paths (concurrent tests install
184 // identical values), so a second write path here only meant the
185 // installer the whole process depends on had no caller under test.
186 TierPrices::from_assumptions(&a).install_global();
187 }
188 }
189
190 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
191 #[derive(Clone, Debug)]
192 pub struct TierCard {
193 pub key: &'static str,
194 pub label: &'static str,
195 pub storage: String,
196 pub founder_monthly: i32,
197 pub standard_monthly: i32,
198 pub founder_annual: i32,
199 pub standard_annual: i32,
200 }
201
202 impl TierPrices {
203 /// Build the four tier cards the dashboard renders. Order matters
204 /// (Basic, Small Files, Big Files, Everything), it's the canonical
205 /// presentation order.
206 pub fn cards(&self) -> Vec<TierCard> {
207 vec![
208 TierCard {
209 key: "basic",
210 label: "Basic",
211 storage: format!("{}, {}/file", self.basic_total, self.basic_per_file),
212 founder_monthly: self.basic_founder,
213 standard_monthly: self.basic_std,
214 founder_annual: self.annual_basic_founder,
215 standard_annual: self.annual_basic_std,
216 },
217 TierCard {
218 key: "small_files",
219 label: "Small Files",
220 storage: format!(
221 "{}, {}/file",
222 self.small_files_total, self.small_files_per_file
223 ),
224 founder_monthly: self.small_files_founder,
225 standard_monthly: self.small_files_std,
226 founder_annual: self.annual_small_files_founder,
227 standard_annual: self.annual_small_files_std,
228 },
229 TierCard {
230 key: "big_files",
231 label: "Big Files",
232 storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file),
233 founder_monthly: self.big_files_founder,
234 standard_monthly: self.big_files_std,
235 founder_annual: self.annual_big_files_founder,
236 standard_annual: self.annual_big_files_std,
237 },
238 TierCard {
239 key: "everything",
240 label: "Everything",
241 storage: format!(
242 "{}, {}/file, all features",
243 self.everything_total, self.everything_per_file
244 ),
245 founder_monthly: self.everything_founder,
246 standard_monthly: self.everything_std,
247 founder_annual: self.annual_everything_founder,
248 standard_annual: self.annual_everything_std,
249 },
250 ]
251 }
252 }
253
254 /// Operator-edited runway figures, loaded once at startup. The
255 /// live paying-creator counts come from the DB at request time and
256 /// are NOT in this struct, see `db::creator_tiers::count_active_paying`
257 /// and `count_trialing_or_grace`.
258 ///
259 /// `quarters` is the cash-runway bucket in whole quarters (rounded down).
260 /// A value of `0` means "not yet published" and the template should
261 /// suppress the line rather than render "0 quarters".
262 ///
263 /// `last_updated_iso` is the date the operator last refreshed the figure,
264 /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated"
265 /// stamp on the disclosure surface.
266 #[derive(Clone, Debug, Default)]
267 pub struct RunwayConfig {
268 pub quarters: i32,
269 pub last_updated_iso: String,
270 }
271
272 impl RunwayConfig {
273 pub fn from_assumptions(a: &Assumptions) -> Self {
274 Self {
275 quarters: int_at(a, "runway.quarters"),
276 last_updated_iso: str_at(a, "runway.last_updated_iso"),
277 }
278 }
279 /// True iff the operator has published a runway figure. Suppress the
280 /// "X quarters at current burn" line when this is false.
281 pub fn is_published(&self) -> bool {
282 self.quarters > 0
283 }
284 }
285
286 fn int_at(a: &Assumptions, key: &str) -> i32 {
287 match a.get(key) {
288 Some(LookupValue::Int(n)) => {
289 i32::try_from(*n).unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32"))
290 }
291 Some(LookupValue::Float(x)) => x.round() as i32,
292 other => panic!("expected integer at {key}, got {other:?}"),
293 }
294 }
295
296 /// Byte counts are i64 (Basic total = 10GB fits, Everything total = 500GB fits;
297 /// hitting i32 max is a ~2 GB tier which we'd never allow, but keep the room).
298 fn bytes_at(a: &Assumptions, key: &str) -> i64 {
299 match a.get(key) {
300 Some(LookupValue::Int(n)) => *n,
301 other => panic!("expected integer at {key}, got {other:?}"),
302 }
303 }
304
305 fn str_at(a: &Assumptions, key: &str) -> String {
306 match a.get(key) {
307 Some(LookupValue::String(s)) => s.clone(),
308 other => panic!("expected string at {key}, got {other:?}"),
309 }
310 }
311
312 #[cfg(test)]
313 mod tests {
314 use super::*;
315
316 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
317
318 #[test]
319 fn runway_config_loads_from_canonical_assumptions() {
320 // The presence of the [runway] block is the only enforced thing,
321 // the values inside are operator-edited. We pin the keys so a
322 // future toml edit that renames `quarters` or `last_updated_iso`
323 // is caught at PR time, not at boot.
324 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
325 let r = RunwayConfig::from_assumptions(&a);
326 assert!(r.quarters >= 0, "quarters must be a non-negative integer");
327 assert!(
328 !r.last_updated_iso.is_empty(),
329 "last_updated_iso must be set"
330 );
331 // ISO 8601 date format: YYYY-MM-DD.
332 assert_eq!(r.last_updated_iso.len(), 10);
333 assert_eq!(r.last_updated_iso.chars().nth(4), Some('-'));
334 assert_eq!(r.last_updated_iso.chars().nth(7), Some('-'));
335 }
336
337 #[test]
338 fn runway_config_is_published_only_when_quarters_nonzero() {
339 // The disclosure template hides the cash-runway bullet when this
340 // returns false, so a freshly-deployed instance with quarters=0
341 // doesn't display "0 quarters at current burn", which would be
342 // both wrong and alarming.
343 let r = RunwayConfig {
344 quarters: 0,
345 last_updated_iso: "2026-06-03".into(),
346 };
347 assert!(!r.is_published());
348 let r = RunwayConfig {
349 quarters: 4,
350 last_updated_iso: "2026-06-03".into(),
351 };
352 assert!(r.is_published());
353 }
354
355 #[test]
356 fn from_canonical_assumptions_populates_every_field() {
357 // Guards every key TierPrices reads. If a future toml edit removes
358 // one of these or flips its type, the panic in `from_assumptions`
359 // fires at startup; this test catches it at PR time instead. All
360 // assertions are *structural* invariants, the literal numbers
361 // live in the toml itself.
362 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
363 let p = TierPrices::from_assumptions(&a);
364
365 // Every price/annual field must be positive.
366 for (name, v) in [
367 ("basic_std", p.basic_std),
368 ("small_files_std", p.small_files_std),
369 ("big_files_std", p.big_files_std),
370 ("everything_std", p.everything_std),
371 ("basic_founder", p.basic_founder),
372 ("small_files_founder", p.small_files_founder),
373 ("big_files_founder", p.big_files_founder),
374 ("everything_founder", p.everything_founder),
375 ("annual_basic_std", p.annual_basic_std),
376 ("annual_small_files_std", p.annual_small_files_std),
377 ("annual_big_files_std", p.annual_big_files_std),
378 ("annual_everything_std", p.annual_everything_std),
379 ("annual_basic_founder", p.annual_basic_founder),
380 ("annual_small_files_founder", p.annual_small_files_founder),
381 ("annual_big_files_founder", p.annual_big_files_founder),
382 ("annual_everything_founder", p.annual_everything_founder),
383 ] {
384 assert!(v > 0, "{name} = {v} must be positive");
385 }
386
387 // Founder is exactly 50% of standard at every tier (policy invariant
388 //, enforced by docengine's `founding ≤ standard` and by pricing
389 // policy in `pricing.md`).
390 assert_eq!(
391 p.basic_founder * 2,
392 p.basic_std,
393 "founder must be 50% of standard"
394 );
395 assert_eq!(p.small_files_founder * 2, p.small_files_std);
396 assert_eq!(p.big_files_founder * 2, p.big_files_std);
397 assert_eq!(p.everything_founder * 2, p.everything_std);
398
399 // Standard is monotone across the tier ladder.
400 assert!(p.basic_std < p.small_files_std);
401 assert!(p.small_files_std < p.big_files_std);
402 assert!(p.big_files_std < p.everything_std);
403
404 // Envelope byte-counts are positive and (for storage totals) non-decreasing.
405 assert!(p.basic_per_file_bytes > 0);
406 assert!(p.basic_total_bytes > 0);
407 assert!(p.basic_total_bytes <= p.small_files_total_bytes);
408 assert!(p.small_files_total_bytes <= p.big_files_total_bytes);
409 assert_eq!(p.big_files_total_bytes, p.everything_total_bytes);
410 assert_eq!(p.big_files_per_file_bytes, p.everything_per_file_bytes);
411
412 // Display strings are non-empty.
413 assert!(!p.basic_per_file.is_empty());
414 assert!(!p.everything_total.is_empty());
415 assert!(!p.cohort_cap_display.is_empty());
416
417 // Cards iteration produces the four canonical rows in canonical order.
418 let cards = p.cards();
419 assert_eq!(cards.len(), 4);
420 assert_eq!(cards[0].key, "basic");
421 assert_eq!(cards[1].key, "small_files");
422 assert_eq!(cards[2].key, "big_files");
423 assert_eq!(cards[3].key, "everything");
424 assert_eq!(cards[1].standard_monthly, p.small_files_std);
425 }
426
427 // The per-tier accessors back `CreatorTier::price_cents` and
428 // `max_file_bytes`, and had no direct test: the tier tests assert
429 // structural invariants (positive, monotone) that hold just as well if the
430 // dollars-to-cents conversion or the tier-to-field mapping is wrong.
431
432 #[test]
433 fn price_cents_for_converts_dollars_to_cents_per_tier() {
434 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
435 let p = TierPrices::from_assumptions(&a);
436 for (tier, dollars) in [
437 (CreatorTier::Basic, p.basic_std),
438 (CreatorTier::SmallFiles, p.small_files_std),
439 (CreatorTier::BigFiles, p.big_files_std),
440 (CreatorTier::Everything, p.everything_std),
441 ] {
442 assert_eq!(
443 p.price_cents_for(tier),
444 dollars * 100,
445 "{tier:?} price is the toml's dollars in cents"
446 );
447 }
448 }
449
450 #[test]
451 fn byte_accessors_read_the_field_belonging_to_the_tier() {
452 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
453 let p = TierPrices::from_assumptions(&a);
454 for (tier, per_file, total) in [
455 (
456 CreatorTier::Basic,
457 p.basic_per_file_bytes,
458 p.basic_total_bytes,
459 ),
460 (
461 CreatorTier::SmallFiles,
462 p.small_files_per_file_bytes,
463 p.small_files_total_bytes,
464 ),
465 (
466 CreatorTier::BigFiles,
467 p.big_files_per_file_bytes,
468 p.big_files_total_bytes,
469 ),
470 (
471 CreatorTier::Everything,
472 p.everything_per_file_bytes,
473 p.everything_total_bytes,
474 ),
475 ] {
476 assert_eq!(p.max_file_bytes_for(tier), per_file, "{tier:?} per-file");
477 assert_eq!(p.max_storage_bytes_for(tier), total, "{tier:?} total");
478 }
479 }
480
481 #[test]
482 fn the_installed_global_is_the_one_the_accessors_read() {
483 // `install_test_default` goes through `install_global`, so this also
484 // pins that the installer actually writes the slot: without it,
485 // `global()` panics on the message below rather than answering.
486 TierPrices::install_test_default();
487 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
488 assert_eq!(
489 TierPrices::global().price_cents_for(CreatorTier::SmallFiles),
490 TierPrices::from_assumptions(&a).price_cents_for(CreatorTier::SmallFiles),
491 );
492 }
493 }
494