Skip to main content

max / makenotwork

18.4 KB · 431 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 let tp = TierPrices::from_assumptions(&a);
183 // Losing the race is the documented outcome rather than a failure:
184 // concurrent tests install identical values.
185 if GLOBAL.set(tp).is_err() {
186 tracing::warn!(
187 "TierPrices::install_test_default raced a concurrent install; the fixture is \
188 ignored and the first table stays in force"
189 );
190 }
191 }
192 }
193
194 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
195 #[derive(Clone, Debug)]
196 pub struct TierCard {
197 pub key: &'static str,
198 pub label: &'static str,
199 pub storage: String,
200 pub founder_monthly: i32,
201 pub standard_monthly: i32,
202 pub founder_annual: i32,
203 pub standard_annual: i32,
204 }
205
206 impl TierPrices {
207 /// Build the four tier cards the dashboard renders. Order matters
208 /// (Basic, Small Files, Big Files, Everything), it's the canonical
209 /// presentation order.
210 pub fn cards(&self) -> Vec<TierCard> {
211 vec![
212 TierCard {
213 key: "basic",
214 label: "Basic",
215 storage: format!("{}, {}/file", self.basic_total, self.basic_per_file),
216 founder_monthly: self.basic_founder,
217 standard_monthly: self.basic_std,
218 founder_annual: self.annual_basic_founder,
219 standard_annual: self.annual_basic_std,
220 },
221 TierCard {
222 key: "small_files",
223 label: "Small Files",
224 storage: format!(
225 "{}, {}/file",
226 self.small_files_total, self.small_files_per_file
227 ),
228 founder_monthly: self.small_files_founder,
229 standard_monthly: self.small_files_std,
230 founder_annual: self.annual_small_files_founder,
231 standard_annual: self.annual_small_files_std,
232 },
233 TierCard {
234 key: "big_files",
235 label: "Big Files",
236 storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file),
237 founder_monthly: self.big_files_founder,
238 standard_monthly: self.big_files_std,
239 founder_annual: self.annual_big_files_founder,
240 standard_annual: self.annual_big_files_std,
241 },
242 TierCard {
243 key: "everything",
244 label: "Everything",
245 storage: format!(
246 "{}, {}/file, all features",
247 self.everything_total, self.everything_per_file
248 ),
249 founder_monthly: self.everything_founder,
250 standard_monthly: self.everything_std,
251 founder_annual: self.annual_everything_founder,
252 standard_annual: self.annual_everything_std,
253 },
254 ]
255 }
256 }
257
258 /// Operator-edited runway figures, loaded once at startup. The
259 /// live paying-creator counts come from the DB at request time and
260 /// are NOT in this struct, see `db::creator_tiers::count_active_paying`
261 /// and `count_trialing_or_grace`.
262 ///
263 /// `quarters` is the cash-runway bucket in whole quarters (rounded down).
264 /// A value of `0` means "not yet published" and the template should
265 /// suppress the line rather than render "0 quarters".
266 ///
267 /// `last_updated_iso` is the date the operator last refreshed the figure,
268 /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated"
269 /// stamp on the disclosure surface.
270 #[derive(Clone, Debug, Default)]
271 pub struct RunwayConfig {
272 pub quarters: i32,
273 pub last_updated_iso: String,
274 }
275
276 impl RunwayConfig {
277 pub fn from_assumptions(a: &Assumptions) -> Self {
278 Self {
279 quarters: int_at(a, "runway.quarters"),
280 last_updated_iso: str_at(a, "runway.last_updated_iso"),
281 }
282 }
283 /// True iff the operator has published a runway figure. Suppress the
284 /// "X quarters at current burn" line when this is false.
285 pub fn is_published(&self) -> bool {
286 self.quarters > 0
287 }
288 }
289
290 fn int_at(a: &Assumptions, key: &str) -> i32 {
291 match a.get(key) {
292 Some(LookupValue::Int(n)) => {
293 i32::try_from(*n).unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32"))
294 }
295 Some(LookupValue::Float(x)) => x.round() as i32,
296 other => panic!("expected integer at {key}, got {other:?}"),
297 }
298 }
299
300 /// Byte counts are i64 (Basic total = 10GB fits, Everything total = 500GB fits;
301 /// hitting i32 max is a ~2 GB tier which we'd never allow, but keep the room).
302 fn bytes_at(a: &Assumptions, key: &str) -> i64 {
303 match a.get(key) {
304 Some(LookupValue::Int(n)) => *n,
305 other => panic!("expected integer at {key}, got {other:?}"),
306 }
307 }
308
309 fn str_at(a: &Assumptions, key: &str) -> String {
310 match a.get(key) {
311 Some(LookupValue::String(s)) => s.clone(),
312 other => panic!("expected string at {key}, got {other:?}"),
313 }
314 }
315
316 #[cfg(test)]
317 mod tests {
318 use super::*;
319
320 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
321
322 #[test]
323 fn runway_config_loads_from_canonical_assumptions() {
324 // The presence of the [runway] block is the only enforced thing,
325 // the values inside are operator-edited. We pin the keys so a
326 // future toml edit that renames `quarters` or `last_updated_iso`
327 // is caught at PR time, not at boot.
328 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
329 let r = RunwayConfig::from_assumptions(&a);
330 assert!(r.quarters >= 0, "quarters must be a non-negative integer");
331 assert!(
332 !r.last_updated_iso.is_empty(),
333 "last_updated_iso must be set"
334 );
335 // ISO 8601 date format: YYYY-MM-DD.
336 assert_eq!(r.last_updated_iso.len(), 10);
337 assert_eq!(r.last_updated_iso.chars().nth(4), Some('-'));
338 assert_eq!(r.last_updated_iso.chars().nth(7), Some('-'));
339 }
340
341 #[test]
342 fn runway_config_is_published_only_when_quarters_nonzero() {
343 // The disclosure template hides the cash-runway bullet when this
344 // returns false, so a freshly-deployed instance with quarters=0
345 // doesn't display "0 quarters at current burn", which would be
346 // both wrong and alarming.
347 let r = RunwayConfig {
348 quarters: 0,
349 last_updated_iso: "2026-06-03".into(),
350 };
351 assert!(!r.is_published());
352 let r = RunwayConfig {
353 quarters: 4,
354 last_updated_iso: "2026-06-03".into(),
355 };
356 assert!(r.is_published());
357 }
358
359 #[test]
360 fn from_canonical_assumptions_populates_every_field() {
361 // Guards every key TierPrices reads. If a future toml edit removes
362 // one of these or flips its type, the panic in `from_assumptions`
363 // fires at startup; this test catches it at PR time instead. All
364 // assertions are *structural* invariants, the literal numbers
365 // live in the toml itself.
366 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
367 let p = TierPrices::from_assumptions(&a);
368
369 // Every price/annual field must be positive.
370 for (name, v) in [
371 ("basic_std", p.basic_std),
372 ("small_files_std", p.small_files_std),
373 ("big_files_std", p.big_files_std),
374 ("everything_std", p.everything_std),
375 ("basic_founder", p.basic_founder),
376 ("small_files_founder", p.small_files_founder),
377 ("big_files_founder", p.big_files_founder),
378 ("everything_founder", p.everything_founder),
379 ("annual_basic_std", p.annual_basic_std),
380 ("annual_small_files_std", p.annual_small_files_std),
381 ("annual_big_files_std", p.annual_big_files_std),
382 ("annual_everything_std", p.annual_everything_std),
383 ("annual_basic_founder", p.annual_basic_founder),
384 ("annual_small_files_founder", p.annual_small_files_founder),
385 ("annual_big_files_founder", p.annual_big_files_founder),
386 ("annual_everything_founder", p.annual_everything_founder),
387 ] {
388 assert!(v > 0, "{name} = {v} must be positive");
389 }
390
391 // Founder is exactly 50% of standard at every tier (policy invariant
392 //, enforced by docengine's `founding ≤ standard` and by pricing
393 // policy in `pricing.md`).
394 assert_eq!(
395 p.basic_founder * 2,
396 p.basic_std,
397 "founder must be 50% of standard"
398 );
399 assert_eq!(p.small_files_founder * 2, p.small_files_std);
400 assert_eq!(p.big_files_founder * 2, p.big_files_std);
401 assert_eq!(p.everything_founder * 2, p.everything_std);
402
403 // Standard is monotone across the tier ladder.
404 assert!(p.basic_std < p.small_files_std);
405 assert!(p.small_files_std < p.big_files_std);
406 assert!(p.big_files_std < p.everything_std);
407
408 // Envelope byte-counts are positive and (for storage totals) non-decreasing.
409 assert!(p.basic_per_file_bytes > 0);
410 assert!(p.basic_total_bytes > 0);
411 assert!(p.basic_total_bytes <= p.small_files_total_bytes);
412 assert!(p.small_files_total_bytes <= p.big_files_total_bytes);
413 assert_eq!(p.big_files_total_bytes, p.everything_total_bytes);
414 assert_eq!(p.big_files_per_file_bytes, p.everything_per_file_bytes);
415
416 // Display strings are non-empty.
417 assert!(!p.basic_per_file.is_empty());
418 assert!(!p.everything_total.is_empty());
419 assert!(!p.cohort_cap_display.is_empty());
420
421 // Cards iteration produces the four canonical rows in canonical order.
422 let cards = p.cards();
423 assert_eq!(cards.len(), 4);
424 assert_eq!(cards[0].key, "basic");
425 assert_eq!(cards[1].key, "small_files");
426 assert_eq!(cards[2].key, "big_files");
427 assert_eq!(cards[3].key, "everything");
428 assert_eq!(cards[1].standard_monthly, p.small_files_std);
429 }
430 }
431