//! 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)] 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, }, ] } } 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 = "../../_private/docs/mnw/server-internal/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 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); } }