//! Tier prices and storage envelopes pulled from `assumptions.toml` at startup. //! //! Templates referencing these via `{{ tier_prices.basic_std }}` etc. stay in //! sync with the docengine substitution system — both read from the same toml. //! A price change is a one-line edit to assumptions.toml + a server restart. //! //! Missing or wrong-typed keys panic at startup (same pattern as //! `Assumptions::validate` failure in main.rs). Production never serves with a //! half-loaded `TierPrices`. use docengine::{Assumptions, LookupValue}; #[derive(Clone, Debug, Default)] pub struct TierPrices { // Standard monthly (post-founder sticker rates). pub basic_std: i32, pub small_files_std: i32, pub big_files_std: i32, pub everything_std: i32, // Founder monthly (50% of standard, locked for life when window closes). pub basic_founder: i32, pub small_files_founder: i32, pub big_files_founder: i32, pub everything_founder: i32, // Standard annual (monthly × 12 × annual_discount.multiplier, rounded). pub annual_basic_std: i32, pub annual_small_files_std: i32, pub annual_big_files_std: i32, pub annual_everything_std: i32, // Founder annual. pub annual_basic_founder: i32, pub annual_small_files_founder: i32, pub annual_big_files_founder: i32, pub annual_everything_founder: i32, // Per-file caps and total storage caps, as display strings ("10MB", "50GB"). pub basic_per_file: String, pub small_files_per_file: String, pub big_files_per_file: String, pub everything_per_file: String, pub basic_total: String, pub small_files_total: String, pub big_files_total: String, pub everything_total: String, // Founder cohort cap, display string with thousands separator ("1,000"). pub cohort_cap_display: String, } impl TierPrices { pub fn from_assumptions(a: &Assumptions) -> Self { Self { basic_std: int_at(a, "tiers.standard.basic"), small_files_std: int_at(a, "tiers.standard.small_files"), big_files_std: int_at(a, "tiers.standard.big_files"), everything_std: int_at(a, "tiers.standard.everything"), basic_founder: int_at(a, "tiers.founding.basic"), small_files_founder: int_at(a, "tiers.founding.small_files"), big_files_founder: int_at(a, "tiers.founding.big_files"), everything_founder: int_at(a, "tiers.founding.everything"), annual_basic_std: int_at(a, "derived.annual_standard_basic"), annual_small_files_std: int_at(a, "derived.annual_standard_small_files"), annual_big_files_std: int_at(a, "derived.annual_standard_big_files"), annual_everything_std: int_at(a, "derived.annual_standard_everything"), annual_basic_founder: int_at(a, "derived.annual_founding_basic"), annual_small_files_founder: int_at(a, "derived.annual_founding_small_files"), annual_big_files_founder: int_at(a, "derived.annual_founding_big_files"), annual_everything_founder: int_at(a, "derived.annual_founding_everything"), basic_per_file: str_at(a, "tier_limits.basic_per_file"), small_files_per_file: str_at(a, "tier_limits.small_files_per_file"), big_files_per_file: str_at(a, "tier_limits.big_files_per_file"), everything_per_file: str_at(a, "tier_limits.everything_per_file"), basic_total: str_at(a, "tier_limits.basic_total"), small_files_total: str_at(a, "tier_limits.small_files_total"), big_files_total: str_at(a, "tier_limits.big_files_total"), everything_total: str_at(a, "tier_limits.everything_total"), cohort_cap_display: str_at(a, "cohort.cap_display"), } } } /// Display row for the dashboard tier-picker grid (`user_creator.html`). #[derive(Clone, Debug)] pub struct TierCard { pub key: &'static str, pub label: &'static str, pub storage: String, pub founder_monthly: i32, pub standard_monthly: i32, pub founder_annual: i32, pub standard_annual: i32, } impl TierPrices { /// Build the four tier cards the dashboard renders. Order matters /// (Basic, Small Files, Big Files, Everything) — it's the canonical /// presentation order. pub fn cards(&self) -> Vec { vec![ TierCard { key: "basic", label: "Basic", storage: format!("{}, {}/file", self.basic_total, self.basic_per_file), founder_monthly: self.basic_founder, standard_monthly: self.basic_std, founder_annual: self.annual_basic_founder, standard_annual: self.annual_basic_std, }, TierCard { key: "small_files", label: "Small Files", storage: format!("{}, {}/file", self.small_files_total, self.small_files_per_file), founder_monthly: self.small_files_founder, standard_monthly: self.small_files_std, founder_annual: self.annual_small_files_founder, standard_annual: self.annual_small_files_std, }, TierCard { key: "big_files", label: "Big Files", storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file), founder_monthly: self.big_files_founder, standard_monthly: self.big_files_std, founder_annual: self.annual_big_files_founder, standard_annual: self.annual_big_files_std, }, TierCard { key: "everything", label: "Everything", storage: format!( "{}, {}/file, all features", self.everything_total, self.everything_per_file ), founder_monthly: self.everything_founder, standard_monthly: self.everything_std, founder_annual: self.annual_everything_founder, standard_annual: self.annual_everything_std, }, ] } } /// One segment of the per-tier monthly fee bar. Sized in cents so the /// template can drop it straight into `flex: ` for a stacked /// horizontal bar that scales proportionally without per-segment math. #[derive(Clone, Debug)] pub struct CostSegment { /// CSS modifier and toml key — `"stripe"`, `"storage"`, `"support"`, /// `"engineering"`, `"reserves"`, `"earnback"`. Drives `.cost-bar-seg-*`. pub kind: &'static str, /// Human label e.g. `"Stripe processing"`. pub label: &'static str, /// Pre-formatted dollar string e.g. `"$0.76"` for the segment + tooltip. pub amount: String, /// Amount in cents — the flex weight for the stacked bar. pub cents: i32, /// Pre-formatted hover text (rendered into `title="…"`). Kept on the /// Rust side so copy reviews land here and not in the template. pub tooltip: String, } /// One row of the cost-allocation widget — a single tier and its six /// fee segments. The template iterates `rows` and per row iterates /// `segments`, so the markup is fully uniform. #[derive(Clone, Debug)] pub struct CostAllocationRow { pub tier_key: &'static str, pub tier_label: &'static str, /// Monthly price in dollars (whole-dollar tiers today). pub tier_price: i32, pub segments: Vec, /// Pre-built `aria-label` for the bar — names every segment + value /// so screen readers get the breakdown. pub aria_label: String, } /// All four tier rows, in canonical Basic → Everything order. #[derive(Clone, Debug, Default)] pub struct CostAllocation { pub rows: Vec, } impl CostAllocation { pub fn from_assumptions(a: &Assumptions, tp: &TierPrices) -> Self { let rows = vec![ row(a, "basic", "Basic", tp.basic_std), row(a, "small_files", "Small Files", tp.small_files_std), row(a, "big_files", "Big Files", tp.big_files_std), row(a, "everything", "Everything", tp.everything_std), ]; Self { rows } } } fn row(a: &Assumptions, key: &'static str, label: &'static str, price: i32) -> CostAllocationRow { let segments = vec![ seg(a, key, "stripe", "Stripe processing", "Stripe processing: {amount}. Stripe's fee on your monthly subscription."), seg(a, key, "storage", "Storage", "Storage: {amount}. At-rest storage, bandwidth, virus scanning, and \ backups at your tier's typical fill, sized to absorb spikes and \ provider price changes without a price increase to you."), seg(a, key, "support", "Human support time", "Human support time: {amount}. Identity recovery, billing disputes, \ moderation, abuse, and legal — the work that can't be automated. \ Per-creator floor; the same at every tier."), seg(a, key, "engineering", "Product engineering", "Product engineering: {amount}. Bug fixes and ongoing product work. \ Per-creator floor; the same at every tier."), seg(a, key, "reserves", "Reserves", "Reserves: {amount}. Held against a bad month — refunds, chargebacks, \ unexpected costs — so a single incident doesn't force a price change \ or shutdown."), seg(a, key, "earnback", "Earn-back (returned to creators)", "Earn-back: {amount}. The margin above cost, reinvested to build the \ platform out and lower prices as we grow, with the rest returned to \ creators as earn-back credit (launching by 2027-01-01). It's how a \ fair platform gets big and stays that way."), ]; let aria_label = build_aria_label(label, price, &segments); CostAllocationRow { tier_key: key, tier_label: label, tier_price: price, segments, aria_label, } } fn seg( a: &Assumptions, tier_key: &str, seg_kind: &'static str, seg_label: &'static str, tooltip_template: &str, ) -> CostSegment { let dollars = float_at(a, &format!("cost_allocation.{tier_key}.{seg_kind}")); let cents = (dollars * 100.0).round() as i32; let amount = format!("${dollars:.2}"); let tooltip = tooltip_template.replace("{amount}", &amount); CostSegment { kind: seg_kind, label: seg_label, amount, cents, tooltip, } } fn build_aria_label(tier_label: &str, price: i32, segments: &[CostSegment]) -> String { let mut s = format!("{tier_label} tier ${price}/mo allocation: "); for (i, seg) in segments.iter().enumerate() { if i > 0 { s.push_str(", "); } s.push_str(seg.label); s.push(' '); s.push_str(&seg.amount); } s } /// Operator-edited runway figures, loaded once at startup. The /// live paying-creator counts come from the DB at request time and /// are NOT in this struct — see `db::creator_tiers::count_active_paying` /// and `count_trialing_or_grace`. /// /// `quarters` is the cash-runway bucket in whole quarters (rounded down). /// A value of `0` means "not yet published" and the template should /// suppress the line rather than render "0 quarters". /// /// `last_updated_iso` is the date the operator last refreshed the figure, /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated" /// stamp on the disclosure surface. #[derive(Clone, Debug, Default)] pub struct RunwayConfig { pub quarters: i32, pub last_updated_iso: String, } impl RunwayConfig { pub fn from_assumptions(a: &Assumptions) -> Self { Self { quarters: int_at(a, "runway.quarters"), last_updated_iso: str_at(a, "runway.last_updated_iso"), } } /// True iff the operator has published a runway figure. Suppress the /// "X quarters at current burn" line when this is false. pub fn is_published(&self) -> bool { self.quarters > 0 } } fn float_at(a: &Assumptions, key: &str) -> f64 { match a.get(key) { Some(LookupValue::Float(x)) => *x, Some(LookupValue::Int(n)) => *n as f64, other => panic!("expected number at {key}, got {other:?}"), } } fn int_at(a: &Assumptions, key: &str) -> i32 { match a.get(key) { Some(LookupValue::Int(n)) => i32::try_from(*n) .unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32")), Some(LookupValue::Float(x)) => x.round() as i32, other => panic!("expected integer at {key}, got {other:?}"), } } fn str_at(a: &Assumptions, key: &str) -> String { match a.get(key) { Some(LookupValue::String(s)) => s.clone(), other => panic!("expected string at {key}, got {other:?}"), } } #[cfg(test)] mod tests { use super::*; const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml"; /// Guards every key TierPrices reads. If a future toml edit removes one of /// these or flips its type, the panic in `from_assumptions` will fire at /// startup; this test catches it at PR time instead. #[test] fn cost_allocation_from_canonical_assumptions_sums_to_tier_price() { // Each row's six segments must sum to the standard tier price. // A future toml edit that breaks that invariant — say a typo in // `cost_allocation.big_files.storage` — would render a stacked // bar whose visible total disagrees with the headline price. // Catch the divergence at PR time. let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let tp = TierPrices::from_assumptions(&a); let alloc = CostAllocation::from_assumptions(&a, &tp); assert_eq!(alloc.rows.len(), 4); let prices = [tp.basic_std, tp.small_files_std, tp.big_files_std, tp.everything_std]; for (row, &price) in alloc.rows.iter().zip(prices.iter()) { assert_eq!(row.tier_price, price); let sum_cents: i32 = row.segments.iter().map(|s| s.cents).sum(); assert_eq!( sum_cents, price * 100, "{tier} segments sum to {sum_cents}¢ but tier price is ${price} = {expected}¢", tier = row.tier_label, expected = price * 100, ); // Six canonical segments in canonical order. let kinds: Vec<&str> = row.segments.iter().map(|s| s.kind).collect(); assert_eq!( kinds, vec!["stripe", "storage", "support", "engineering", "reserves", "earnback"], ); } // Spot-check the per-segment values match what we agreed in the // economics doc. A drift here means the toml was edited without // a corresponding rationale update. let basic = &alloc.rows[0]; assert_eq!(basic.segments[0].amount, "$0.76"); // stripe assert_eq!(basic.segments[5].amount, "$1.84"); // earnback let everything = &alloc.rows[3]; assert_eq!(everything.segments[2].amount, "$5.00"); // support flat assert_eq!(everything.segments[3].amount, "$6.00"); // engineering flat assert_eq!(everything.segments[4].amount, "$7.50"); // reserves 12.5% of $60 } #[test] fn runway_config_loads_from_canonical_assumptions() { // The presence of the [runway] block is the only enforced thing — // the values inside are operator-edited. We pin the keys so a // future toml edit that renames `quarters` or `last_updated_iso` // is caught at PR time, not at boot. let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let r = RunwayConfig::from_assumptions(&a); assert!(r.quarters >= 0, "quarters must be a non-negative integer"); assert!(!r.last_updated_iso.is_empty(), "last_updated_iso must be set"); // ISO 8601 date format: YYYY-MM-DD. assert_eq!(r.last_updated_iso.len(), 10); assert!(r.last_updated_iso.chars().nth(4) == Some('-')); assert!(r.last_updated_iso.chars().nth(7) == Some('-')); } #[test] fn runway_config_is_published_only_when_quarters_nonzero() { // The disclosure template hides the cash-runway bullet when this // returns false, so a freshly-deployed instance with quarters=0 // doesn't display "0 quarters at current burn" — which would be // both wrong and alarming. let r = RunwayConfig { quarters: 0, last_updated_iso: "2026-06-03".into() }; assert!(!r.is_published()); let r = RunwayConfig { quarters: 4, last_updated_iso: "2026-06-03".into() }; assert!(r.is_published()); } #[test] fn cost_allocation_aria_label_names_every_segment() { // Screen readers get the full breakdown via aria-label since the // colored bar carries no native semantics. Pin the format. let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let tp = TierPrices::from_assumptions(&a); let alloc = CostAllocation::from_assumptions(&a, &tp); let aria = &alloc.rows[0].aria_label; assert!(aria.starts_with("Basic tier $16/mo allocation: ")); for label in ["Stripe processing", "Storage", "Human support time", "Product engineering", "Reserves", "Earn-back (returned to creators)"] { assert!(aria.contains(label), "aria-label missing {label}: {aria}"); } } #[test] fn from_canonical_assumptions_populates_every_field() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let p = TierPrices::from_assumptions(&a); // Sanity-check sentinels (values match the toml; if they change in // toml, update here too). assert_eq!(p.basic_std, 16); assert_eq!(p.everything_std, 60); assert_eq!(p.basic_founder, 8); assert_eq!(p.annual_basic_std, 173); assert_eq!(p.annual_everything_founder, 324); assert_eq!(p.basic_per_file, "10MB"); assert_eq!(p.everything_total, "500GB"); assert_eq!(p.cohort_cap_display, "1,000"); // Cards iteration produces the four canonical rows. let cards = p.cards(); assert_eq!(cards.len(), 4); assert_eq!(cards[0].key, "basic"); assert_eq!(cards[3].key, "everything"); assert_eq!(cards[1].standard_monthly, 24); } }