Skip to main content

max / makenotwork

18.2 KB · 434 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 use docengine::{Assumptions, LookupValue};
12
13 #[derive(Clone, Debug, Default)]
14 pub struct TierPrices {
15 // Standard monthly (post-founder sticker rates).
16 pub basic_std: i32,
17 pub small_files_std: i32,
18 pub big_files_std: i32,
19 pub everything_std: i32,
20 // Founder monthly (50% of standard, locked for life when window closes).
21 pub basic_founder: i32,
22 pub small_files_founder: i32,
23 pub big_files_founder: i32,
24 pub everything_founder: i32,
25 // Standard annual (monthly × 12 × annual_discount.multiplier, rounded).
26 pub annual_basic_std: i32,
27 pub annual_small_files_std: i32,
28 pub annual_big_files_std: i32,
29 pub annual_everything_std: i32,
30 // Founder annual.
31 pub annual_basic_founder: i32,
32 pub annual_small_files_founder: i32,
33 pub annual_big_files_founder: i32,
34 pub annual_everything_founder: i32,
35 // Per-file caps and total storage caps, as display strings ("10MB", "50GB").
36 pub basic_per_file: String,
37 pub small_files_per_file: String,
38 pub big_files_per_file: String,
39 pub everything_per_file: String,
40 pub basic_total: String,
41 pub small_files_total: String,
42 pub big_files_total: String,
43 pub everything_total: String,
44 // Founder cohort cap, display string with thousands separator ("1,000").
45 pub cohort_cap_display: String,
46 }
47
48 impl TierPrices {
49 pub fn from_assumptions(a: &Assumptions) -> Self {
50 Self {
51 basic_std: int_at(a, "tiers.standard.basic"),
52 small_files_std: int_at(a, "tiers.standard.small_files"),
53 big_files_std: int_at(a, "tiers.standard.big_files"),
54 everything_std: int_at(a, "tiers.standard.everything"),
55 basic_founder: int_at(a, "tiers.founding.basic"),
56 small_files_founder: int_at(a, "tiers.founding.small_files"),
57 big_files_founder: int_at(a, "tiers.founding.big_files"),
58 everything_founder: int_at(a, "tiers.founding.everything"),
59 annual_basic_std: int_at(a, "derived.annual_standard_basic"),
60 annual_small_files_std: int_at(a, "derived.annual_standard_small_files"),
61 annual_big_files_std: int_at(a, "derived.annual_standard_big_files"),
62 annual_everything_std: int_at(a, "derived.annual_standard_everything"),
63 annual_basic_founder: int_at(a, "derived.annual_founding_basic"),
64 annual_small_files_founder: int_at(a, "derived.annual_founding_small_files"),
65 annual_big_files_founder: int_at(a, "derived.annual_founding_big_files"),
66 annual_everything_founder: int_at(a, "derived.annual_founding_everything"),
67 basic_per_file: str_at(a, "tier_limits.basic_per_file"),
68 small_files_per_file: str_at(a, "tier_limits.small_files_per_file"),
69 big_files_per_file: str_at(a, "tier_limits.big_files_per_file"),
70 everything_per_file: str_at(a, "tier_limits.everything_per_file"),
71 basic_total: str_at(a, "tier_limits.basic_total"),
72 small_files_total: str_at(a, "tier_limits.small_files_total"),
73 big_files_total: str_at(a, "tier_limits.big_files_total"),
74 everything_total: str_at(a, "tier_limits.everything_total"),
75 cohort_cap_display: str_at(a, "cohort.cap_display"),
76 }
77 }
78 }
79
80 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
81 #[derive(Clone, Debug)]
82 pub struct TierCard {
83 pub key: &'static str,
84 pub label: &'static str,
85 pub storage: String,
86 pub founder_monthly: i32,
87 pub standard_monthly: i32,
88 pub founder_annual: i32,
89 pub standard_annual: i32,
90 }
91
92 impl TierPrices {
93 /// Build the four tier cards the dashboard renders. Order matters
94 /// (Basic, Small Files, Big Files, Everything) — it's the canonical
95 /// presentation order.
96 pub fn cards(&self) -> Vec<TierCard> {
97 vec![
98 TierCard {
99 key: "basic",
100 label: "Basic",
101 storage: format!("{}, {}/file", self.basic_total, self.basic_per_file),
102 founder_monthly: self.basic_founder,
103 standard_monthly: self.basic_std,
104 founder_annual: self.annual_basic_founder,
105 standard_annual: self.annual_basic_std,
106 },
107 TierCard {
108 key: "small_files",
109 label: "Small Files",
110 storage: format!("{}, {}/file", self.small_files_total, self.small_files_per_file),
111 founder_monthly: self.small_files_founder,
112 standard_monthly: self.small_files_std,
113 founder_annual: self.annual_small_files_founder,
114 standard_annual: self.annual_small_files_std,
115 },
116 TierCard {
117 key: "big_files",
118 label: "Big Files",
119 storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file),
120 founder_monthly: self.big_files_founder,
121 standard_monthly: self.big_files_std,
122 founder_annual: self.annual_big_files_founder,
123 standard_annual: self.annual_big_files_std,
124 },
125 TierCard {
126 key: "everything",
127 label: "Everything",
128 storage: format!(
129 "{}, {}/file, all features",
130 self.everything_total, self.everything_per_file
131 ),
132 founder_monthly: self.everything_founder,
133 standard_monthly: self.everything_std,
134 founder_annual: self.annual_everything_founder,
135 standard_annual: self.annual_everything_std,
136 },
137 ]
138 }
139 }
140
141 /// One segment of the per-tier monthly fee bar. Sized in cents so the
142 /// template can drop it straight into `flex: <cents>` for a stacked
143 /// horizontal bar that scales proportionally without per-segment math.
144 #[derive(Clone, Debug)]
145 pub struct CostSegment {
146 /// CSS modifier and toml key — `"stripe"`, `"storage"`, `"support"`,
147 /// `"engineering"`, `"reserves"`, `"earnback"`. Drives `.cost-bar-seg-*`.
148 pub kind: &'static str,
149 /// Human label e.g. `"Stripe processing"`.
150 pub label: &'static str,
151 /// Pre-formatted dollar string e.g. `"$0.76"` for the segment + tooltip.
152 pub amount: String,
153 /// Amount in cents — the flex weight for the stacked bar.
154 pub cents: i32,
155 /// Pre-formatted hover text (rendered into `title="…"`). Kept on the
156 /// Rust side so copy reviews land here and not in the template.
157 pub tooltip: String,
158 }
159
160 /// One row of the cost-allocation widget — a single tier and its six
161 /// fee segments. The template iterates `rows` and per row iterates
162 /// `segments`, so the markup is fully uniform.
163 #[derive(Clone, Debug)]
164 pub struct CostAllocationRow {
165 pub tier_key: &'static str,
166 pub tier_label: &'static str,
167 /// Monthly price in dollars (whole-dollar tiers today).
168 pub tier_price: i32,
169 pub segments: Vec<CostSegment>,
170 /// Pre-built `aria-label` for the bar — names every segment + value
171 /// so screen readers get the breakdown.
172 pub aria_label: String,
173 }
174
175 /// All four tier rows, in canonical Basic → Everything order.
176 #[derive(Clone, Debug, Default)]
177 pub struct CostAllocation {
178 pub rows: Vec<CostAllocationRow>,
179 }
180
181 impl CostAllocation {
182 pub fn from_assumptions(a: &Assumptions, tp: &TierPrices) -> Self {
183 let rows = vec![
184 row(a, "basic", "Basic", tp.basic_std),
185 row(a, "small_files", "Small Files", tp.small_files_std),
186 row(a, "big_files", "Big Files", tp.big_files_std),
187 row(a, "everything", "Everything", tp.everything_std),
188 ];
189 Self { rows }
190 }
191 }
192
193 fn row(a: &Assumptions, key: &'static str, label: &'static str, price: i32) -> CostAllocationRow {
194 let segments = vec![
195 seg(a, key, "stripe", "Stripe processing",
196 "Stripe processing: {amount}. Stripe's fee on your monthly subscription."),
197 seg(a, key, "storage", "Storage",
198 "Storage: {amount}. At-rest storage, bandwidth, virus scanning, and \
199 backups at your tier's typical fill, sized to absorb spikes and \
200 provider price changes without a price increase to you."),
201 seg(a, key, "support", "Human support time",
202 "Human support time: {amount}. Identity recovery, billing disputes, \
203 moderation, abuse, and legal — the work that can't be automated. \
204 Per-creator floor; the same at every tier."),
205 seg(a, key, "engineering", "Product engineering",
206 "Product engineering: {amount}. Bug fixes and ongoing product work. \
207 Per-creator floor; the same at every tier."),
208 seg(a, key, "reserves", "Reserves",
209 "Reserves: {amount}. Held against a bad month — refunds, chargebacks, \
210 unexpected costs — so a single incident doesn't force a price change \
211 or shutdown."),
212 seg(a, key, "earnback", "Earn-back (returned to creators)",
213 "Earn-back: {amount}. The margin above cost, reinvested to build the \
214 platform out and lower prices as we grow, with the rest returned to \
215 creators as earn-back credit (launching by 2027-01-01). It's how a \
216 fair platform gets big and stays that way."),
217 ];
218 let aria_label = build_aria_label(label, price, &segments);
219 CostAllocationRow {
220 tier_key: key,
221 tier_label: label,
222 tier_price: price,
223 segments,
224 aria_label,
225 }
226 }
227
228 fn seg(
229 a: &Assumptions,
230 tier_key: &str,
231 seg_kind: &'static str,
232 seg_label: &'static str,
233 tooltip_template: &str,
234 ) -> CostSegment {
235 let dollars = float_at(a, &format!("cost_allocation.{tier_key}.{seg_kind}"));
236 let cents = (dollars * 100.0).round() as i32;
237 let amount = format!("${dollars:.2}");
238 let tooltip = tooltip_template.replace("{amount}", &amount);
239 CostSegment {
240 kind: seg_kind,
241 label: seg_label,
242 amount,
243 cents,
244 tooltip,
245 }
246 }
247
248 fn build_aria_label(tier_label: &str, price: i32, segments: &[CostSegment]) -> String {
249 let mut s = format!("{tier_label} tier ${price}/mo allocation: ");
250 for (i, seg) in segments.iter().enumerate() {
251 if i > 0 { s.push_str(", "); }
252 s.push_str(seg.label);
253 s.push(' ');
254 s.push_str(&seg.amount);
255 }
256 s
257 }
258
259 /// Operator-edited runway figures, loaded once at startup. The
260 /// live paying-creator counts come from the DB at request time and
261 /// are NOT in this struct — see `db::creator_tiers::count_active_paying`
262 /// and `count_trialing_or_grace`.
263 ///
264 /// `quarters` is the cash-runway bucket in whole quarters (rounded down).
265 /// A value of `0` means "not yet published" and the template should
266 /// suppress the line rather than render "0 quarters".
267 ///
268 /// `last_updated_iso` is the date the operator last refreshed the figure,
269 /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated"
270 /// stamp on the disclosure surface.
271 #[derive(Clone, Debug, Default)]
272 pub struct RunwayConfig {
273 pub quarters: i32,
274 pub last_updated_iso: String,
275 }
276
277 impl RunwayConfig {
278 pub fn from_assumptions(a: &Assumptions) -> Self {
279 Self {
280 quarters: int_at(a, "runway.quarters"),
281 last_updated_iso: str_at(a, "runway.last_updated_iso"),
282 }
283 }
284 /// True iff the operator has published a runway figure. Suppress the
285 /// "X quarters at current burn" line when this is false.
286 pub fn is_published(&self) -> bool {
287 self.quarters > 0
288 }
289 }
290
291 fn float_at(a: &Assumptions, key: &str) -> f64 {
292 match a.get(key) {
293 Some(LookupValue::Float(x)) => *x,
294 Some(LookupValue::Int(n)) => *n as f64,
295 other => panic!("expected number at {key}, got {other:?}"),
296 }
297 }
298
299 fn int_at(a: &Assumptions, key: &str) -> i32 {
300 match a.get(key) {
301 Some(LookupValue::Int(n)) => i32::try_from(*n)
302 .unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32")),
303 Some(LookupValue::Float(x)) => x.round() as i32,
304 other => panic!("expected integer at {key}, got {other:?}"),
305 }
306 }
307
308 fn str_at(a: &Assumptions, key: &str) -> String {
309 match a.get(key) {
310 Some(LookupValue::String(s)) => s.clone(),
311 other => panic!("expected string at {key}, got {other:?}"),
312 }
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318
319 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
320
321 /// Guards every key TierPrices reads. If a future toml edit removes one of
322 /// these or flips its type, the panic in `from_assumptions` will fire at
323 /// startup; this test catches it at PR time instead.
324 #[test]
325 fn cost_allocation_from_canonical_assumptions_sums_to_tier_price() {
326 // Each row's six segments must sum to the standard tier price.
327 // A future toml edit that breaks that invariant — say a typo in
328 // `cost_allocation.big_files.storage` — would render a stacked
329 // bar whose visible total disagrees with the headline price.
330 // Catch the divergence at PR time.
331 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
332 let tp = TierPrices::from_assumptions(&a);
333 let alloc = CostAllocation::from_assumptions(&a, &tp);
334 assert_eq!(alloc.rows.len(), 4);
335
336 let prices = [tp.basic_std, tp.small_files_std, tp.big_files_std, tp.everything_std];
337 for (row, &price) in alloc.rows.iter().zip(prices.iter()) {
338 assert_eq!(row.tier_price, price);
339 let sum_cents: i32 = row.segments.iter().map(|s| s.cents).sum();
340 assert_eq!(
341 sum_cents,
342 price * 100,
343 "{tier} segments sum to {sum_cents}¢ but tier price is ${price} = {expected}¢",
344 tier = row.tier_label,
345 expected = price * 100,
346 );
347 // Six canonical segments in canonical order.
348 let kinds: Vec<&str> = row.segments.iter().map(|s| s.kind).collect();
349 assert_eq!(
350 kinds,
351 vec!["stripe", "storage", "support", "engineering", "reserves", "earnback"],
352 );
353 }
354
355 // Spot-check the per-segment values match what we agreed in the
356 // economics doc. A drift here means the toml was edited without
357 // a corresponding rationale update.
358 let basic = &alloc.rows[0];
359 assert_eq!(basic.segments[0].amount, "$0.76"); // stripe
360 assert_eq!(basic.segments[5].amount, "$1.84"); // earnback
361 let everything = &alloc.rows[3];
362 assert_eq!(everything.segments[2].amount, "$5.00"); // support flat
363 assert_eq!(everything.segments[3].amount, "$6.00"); // engineering flat
364 assert_eq!(everything.segments[4].amount, "$7.50"); // reserves 12.5% of $60
365 }
366
367 #[test]
368 fn runway_config_loads_from_canonical_assumptions() {
369 // The presence of the [runway] block is the only enforced thing —
370 // the values inside are operator-edited. We pin the keys so a
371 // future toml edit that renames `quarters` or `last_updated_iso`
372 // is caught at PR time, not at boot.
373 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
374 let r = RunwayConfig::from_assumptions(&a);
375 assert!(r.quarters >= 0, "quarters must be a non-negative integer");
376 assert!(!r.last_updated_iso.is_empty(), "last_updated_iso must be set");
377 // ISO 8601 date format: YYYY-MM-DD.
378 assert_eq!(r.last_updated_iso.len(), 10);
379 assert!(r.last_updated_iso.chars().nth(4) == Some('-'));
380 assert!(r.last_updated_iso.chars().nth(7) == Some('-'));
381 }
382
383 #[test]
384 fn runway_config_is_published_only_when_quarters_nonzero() {
385 // The disclosure template hides the cash-runway bullet when this
386 // returns false, so a freshly-deployed instance with quarters=0
387 // doesn't display "0 quarters at current burn" — which would be
388 // both wrong and alarming.
389 let r = RunwayConfig { quarters: 0, last_updated_iso: "2026-06-03".into() };
390 assert!(!r.is_published());
391 let r = RunwayConfig { quarters: 4, last_updated_iso: "2026-06-03".into() };
392 assert!(r.is_published());
393 }
394
395 #[test]
396 fn cost_allocation_aria_label_names_every_segment() {
397 // Screen readers get the full breakdown via aria-label since the
398 // colored bar carries no native semantics. Pin the format.
399 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
400 let tp = TierPrices::from_assumptions(&a);
401 let alloc = CostAllocation::from_assumptions(&a, &tp);
402 let aria = &alloc.rows[0].aria_label;
403 assert!(aria.starts_with("Basic tier $16/mo allocation: "));
404 for label in ["Stripe processing", "Storage", "Human support time",
405 "Product engineering", "Reserves", "Earn-back (returned to creators)"] {
406 assert!(aria.contains(label), "aria-label missing {label}: {aria}");
407 }
408 }
409
410 #[test]
411 fn from_canonical_assumptions_populates_every_field() {
412 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
413 let p = TierPrices::from_assumptions(&a);
414
415 // Sanity-check sentinels (values match the toml; if they change in
416 // toml, update here too).
417 assert_eq!(p.basic_std, 16);
418 assert_eq!(p.everything_std, 60);
419 assert_eq!(p.basic_founder, 8);
420 assert_eq!(p.annual_basic_std, 173);
421 assert_eq!(p.annual_everything_founder, 324);
422 assert_eq!(p.basic_per_file, "10MB");
423 assert_eq!(p.everything_total, "500GB");
424 assert_eq!(p.cohort_cap_display, "1,000");
425
426 // Cards iteration produces the four canonical rows.
427 let cards = p.cards();
428 assert_eq!(cards.len(), 4);
429 assert_eq!(cards[0].key, "basic");
430 assert_eq!(cards[3].key, "everything");
431 assert_eq!(cards[1].standard_monthly, 24);
432 }
433 }
434