Skip to main content

max / makenotwork

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