//! 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`. //! //! `TierPrices::install_global` writes the loaded instance into a process-wide //! `OnceLock` so `CreatorTier` accessors (`price_cents`, `max_file_bytes`, //! `max_storage_bytes`) can read from it without threading state through every //! caller. `main.rs` calls it before the server binds; tests call //! `install_test_default` before touching CreatorTier. use std::sync::OnceLock; use mnw_assumptions::{Assumptions, LookupValue}; use crate::db::CreatorTier; static GLOBAL: OnceLock = OnceLock::new(); #[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, // Same envelopes as machine-readable byte counts (from [tier_bytes]). // The docengine validator asserts these parse back to the display strings // above, so drift between the two forms fails at boot. pub basic_per_file_bytes: i64, pub small_files_per_file_bytes: i64, pub big_files_per_file_bytes: i64, pub everything_per_file_bytes: i64, pub basic_total_bytes: i64, pub small_files_total_bytes: i64, pub big_files_total_bytes: i64, pub everything_total_bytes: i64, // 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"), basic_per_file_bytes: bytes_at(a, "tier_bytes.basic_per_file"), small_files_per_file_bytes: bytes_at(a, "tier_bytes.small_files_per_file"), big_files_per_file_bytes: bytes_at(a, "tier_bytes.big_files_per_file"), everything_per_file_bytes: bytes_at(a, "tier_bytes.everything_per_file"), basic_total_bytes: bytes_at(a, "tier_bytes.basic_total"), small_files_total_bytes: bytes_at(a, "tier_bytes.small_files_total"), big_files_total_bytes: bytes_at(a, "tier_bytes.big_files_total"), everything_total_bytes: bytes_at(a, "tier_bytes.everything_total"), cohort_cap_display: str_at(a, "cohort.cap_display"), } } /// Monthly standard price in cents for the given tier. Backs /// `CreatorTier::price_cents`, see the OnceLock note at the top of this /// module. pub fn price_cents_for(&self, tier: CreatorTier) -> i32 { (match tier { CreatorTier::Basic => self.basic_std, CreatorTier::SmallFiles => self.small_files_std, CreatorTier::BigFiles => self.big_files_std, CreatorTier::Everything => self.everything_std, }) * 100 } /// Per-upload byte cap for the given tier. pub fn max_file_bytes_for(&self, tier: CreatorTier) -> i64 { match tier { CreatorTier::Basic => self.basic_per_file_bytes, CreatorTier::SmallFiles => self.small_files_per_file_bytes, CreatorTier::BigFiles => self.big_files_per_file_bytes, CreatorTier::Everything => self.everything_per_file_bytes, } } /// Total storage byte cap for the given tier. pub fn max_storage_bytes_for(&self, tier: CreatorTier) -> i64 { match tier { CreatorTier::Basic => self.basic_total_bytes, CreatorTier::SmallFiles => self.small_files_total_bytes, CreatorTier::BigFiles => self.big_files_total_bytes, CreatorTier::Everything => self.everything_total_bytes, } } /// Install this instance as the process-wide `CreatorTier` config source. /// Called from `main.rs` once, before any request handling. Subsequent /// calls are ignored (OnceLock semantics); production installs exactly /// once. pub fn install_global(self) { if GLOBAL.set(self).is_err() { // Not fatal (the first install is the live one and prices are read // from it either way), but in production this is called exactly // once, so a second call means two config sources exist and the // second one is being ignored. tracing::warn!( "TierPrices::install_global called a second time; the new table is ignored and \ the first install stays in force" ); } } /// Read the installed global. Panics if `install_global` hasn't been /// called, same failure mode as boot-time toml validation. pub fn global() -> &'static TierPrices { GLOBAL.get().expect( "TierPrices::install_global was not called before CreatorTier accessor use, \ call install_global in main.rs or TierPrices::install_test_default in a test", ) } /// Install the canonical fixture into the global slot for test use. /// Idempotent; safe to call from multiple tests concurrently. Not /// cfg-gated so it is available to integration-test harnesses regardless /// of whether the gate build is debug or release; never called in prod /// (main.rs installs from the live assumptions instead). pub fn install_test_default() { // If already installed (either by an earlier test or by an integration // harness), leave it, the values are stable across tests. if GLOBAL.get().is_some() { return; } // Path is relative to the crate root at test time. let a = Assumptions::load("docs/business/assumptions.toml") .expect("test setup: load canonical assumptions.toml"); // Through `install_global` rather than around it: losing the race is // the documented outcome in both paths (concurrent tests install // identical values), so a second write path here only meant the // installer the whole process depends on had no caller under test. TierPrices::from_assumptions(&a).install_global(); } } /// 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, }, ] } } /// 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 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:?}"), } } /// Byte counts are i64 (Basic total = 10GB fits, Everything total = 500GB fits; /// hitting i32 max is a ~2 GB tier which we'd never allow, but keep the room). fn bytes_at(a: &Assumptions, key: &str) -> i64 { match a.get(key) { Some(LookupValue::Int(n)) => *n, 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"; #[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_eq!(r.last_updated_iso.chars().nth(4), Some('-')); assert_eq!(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 from_canonical_assumptions_populates_every_field() { // Guards every key TierPrices reads. If a future toml edit removes // one of these or flips its type, the panic in `from_assumptions` // fires at startup; this test catches it at PR time instead. All // assertions are *structural* invariants, the literal numbers // live in the toml itself. let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let p = TierPrices::from_assumptions(&a); // Every price/annual field must be positive. for (name, v) in [ ("basic_std", p.basic_std), ("small_files_std", p.small_files_std), ("big_files_std", p.big_files_std), ("everything_std", p.everything_std), ("basic_founder", p.basic_founder), ("small_files_founder", p.small_files_founder), ("big_files_founder", p.big_files_founder), ("everything_founder", p.everything_founder), ("annual_basic_std", p.annual_basic_std), ("annual_small_files_std", p.annual_small_files_std), ("annual_big_files_std", p.annual_big_files_std), ("annual_everything_std", p.annual_everything_std), ("annual_basic_founder", p.annual_basic_founder), ("annual_small_files_founder", p.annual_small_files_founder), ("annual_big_files_founder", p.annual_big_files_founder), ("annual_everything_founder", p.annual_everything_founder), ] { assert!(v > 0, "{name} = {v} must be positive"); } // Founder is exactly 50% of standard at every tier (policy invariant //, enforced by docengine's `founding ≤ standard` and by pricing // policy in `pricing.md`). assert_eq!( p.basic_founder * 2, p.basic_std, "founder must be 50% of standard" ); assert_eq!(p.small_files_founder * 2, p.small_files_std); assert_eq!(p.big_files_founder * 2, p.big_files_std); assert_eq!(p.everything_founder * 2, p.everything_std); // Standard is monotone across the tier ladder. assert!(p.basic_std < p.small_files_std); assert!(p.small_files_std < p.big_files_std); assert!(p.big_files_std < p.everything_std); // Envelope byte-counts are positive and (for storage totals) non-decreasing. assert!(p.basic_per_file_bytes > 0); assert!(p.basic_total_bytes > 0); assert!(p.basic_total_bytes <= p.small_files_total_bytes); assert!(p.small_files_total_bytes <= p.big_files_total_bytes); assert_eq!(p.big_files_total_bytes, p.everything_total_bytes); assert_eq!(p.big_files_per_file_bytes, p.everything_per_file_bytes); // Display strings are non-empty. assert!(!p.basic_per_file.is_empty()); assert!(!p.everything_total.is_empty()); assert!(!p.cohort_cap_display.is_empty()); // Cards iteration produces the four canonical rows in canonical order. let cards = p.cards(); assert_eq!(cards.len(), 4); assert_eq!(cards[0].key, "basic"); assert_eq!(cards[1].key, "small_files"); assert_eq!(cards[2].key, "big_files"); assert_eq!(cards[3].key, "everything"); assert_eq!(cards[1].standard_monthly, p.small_files_std); } // The per-tier accessors back `CreatorTier::price_cents` and // `max_file_bytes`, and had no direct test: the tier tests assert // structural invariants (positive, monotone) that hold just as well if the // dollars-to-cents conversion or the tier-to-field mapping is wrong. #[test] fn price_cents_for_converts_dollars_to_cents_per_tier() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let p = TierPrices::from_assumptions(&a); for (tier, dollars) in [ (CreatorTier::Basic, p.basic_std), (CreatorTier::SmallFiles, p.small_files_std), (CreatorTier::BigFiles, p.big_files_std), (CreatorTier::Everything, p.everything_std), ] { assert_eq!( p.price_cents_for(tier), dollars * 100, "{tier:?} price is the toml's dollars in cents" ); } } #[test] fn byte_accessors_read_the_field_belonging_to_the_tier() { let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); let p = TierPrices::from_assumptions(&a); for (tier, per_file, total) in [ ( CreatorTier::Basic, p.basic_per_file_bytes, p.basic_total_bytes, ), ( CreatorTier::SmallFiles, p.small_files_per_file_bytes, p.small_files_total_bytes, ), ( CreatorTier::BigFiles, p.big_files_per_file_bytes, p.big_files_total_bytes, ), ( CreatorTier::Everything, p.everything_per_file_bytes, p.everything_total_bytes, ), ] { assert_eq!(p.max_file_bytes_for(tier), per_file, "{tier:?} per-file"); assert_eq!(p.max_storage_bytes_for(tier), total, "{tier:?} total"); } } #[test] fn the_installed_global_is_the_one_the_accessors_read() { // `install_test_default` goes through `install_global`, so this also // pins that the installer actually writes the slot: without it, // `global()` panics on the message below rather than answering. TierPrices::install_test_default(); let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); assert_eq!( TierPrices::global().price_cents_for(CreatorTier::SmallFiles), TierPrices::from_assumptions(&a).price_cents_for(CreatorTier::SmallFiles), ); } }