//! Build-time substitution of MNW business assumptions into markdown. //! //! //! //! Loads a TOML "source of truth" file, computes a registry of derived values //! (Stripe fee math, tier pricing, break-even, founding-tier discounts, …), //! validates internal consistency, and substitutes `{{ dotted.path }}` markers //! in markdown before rendering. //! //! The generic `{{ path | filter }}` engine lives in the [`subst`] crate; this //! crate is the MNW-specific layer on top: the typed mirror of //! `assumptions.toml`, the derived-value calculator, and the validation rules. //! //! The intended pipeline is: //! //! ```ignore //! let assumptions = Assumptions::load("assumptions.toml")?; //! assumptions.validate()?; //! let resolved = assumptions.substitute(&markdown)?; //! let html = docengine::render_permissive(&resolved); //! ``` //! //! Substitution runs on raw markdown before parsing so values may appear //! anywhere: prose, code spans, table cells, link text. use std::collections::HashMap; use std::fmt; use std::fs; use std::path::Path; use serde::Deserialize; use subst::{SubstError, Substituter, Value}; // Re-export the generic engine's public surface so consumers can register // custom filters without depending on `subst` directly. `LookupValue` is the // leaf value type. pub use subst::{Filter, FilterArg, FilterError, Value as LookupValue, code_span_ranges}; // --- public types /// Top-level errors returned by [`Assumptions::load`] / [`substitute`]. #[derive(Debug)] pub enum AssumptionsError { Io(std::io::Error), Parse(toml::de::Error), Validation(Vec), Substitution { unresolved: Vec }, } impl fmt::Display for AssumptionsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(e) => write!(f, "I/O error: {e}"), Self::Parse(e) => write!(f, "TOML parse error: {e}"), Self::Validation(failures) => { writeln!(f, "validation failed ({} rule(s)):", failures.len())?; for rule in failures { writeln!(f, " - {rule}")?; } Ok(()) } Self::Substitution { unresolved } => { write!(f, "unresolved placeholders: {}", unresolved.join(", ")) } } } } impl std::error::Error for AssumptionsError {} impl From for AssumptionsError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } impl From for AssumptionsError { fn from(e: toml::de::Error) -> Self { Self::Parse(e) } } impl From for AssumptionsError { fn from(e: SubstError) -> Self { let SubstError::Unresolved(unresolved) = e; Self::Substitution { unresolved } } } /// Loaded + validated business assumptions plus a populated substitution engine. pub struct Assumptions { typed: Typed, subst: Substituter, } impl Assumptions { /// Load assumptions from a TOML file. pub fn load>(path: P) -> Result { let text = fs::read_to_string(path)?; Self::parse(&text) } /// Parse assumptions from a TOML string. pub fn parse(text: &str) -> Result { let value: toml::Value = toml::from_str(text)?; let typed: Typed = value.clone().try_into()?; let mut lookup = HashMap::new(); walk_value(&value, String::new(), &mut lookup); insert_derived(&typed, &mut lookup); let mut subst = Substituter::new(); for (k, v) in lookup { subst.insert(k, v); } Ok(Self { typed, subst }) } /// Register a custom filter. Overrides any built-in or previously /// registered filter with the same name. /// /// ```ignore /// let a = Assumptions::load(path)? /// .with_filter("k", |v, _args| { /// let n = v.as_f64().ok_or_else(|| FilterError::type_error("k", &v))?; /// Ok(LookupValue::String(format!("{:.0}K", n / 1000.0))) /// }); /// ``` #[must_use] pub fn with_filter(mut self, name: impl Into, filter: impl Filter + 'static) -> Self { self.subst = self.subst.with_filter(name, filter); self } /// Run all consistency checks. Returns `Err` listing every failed rule. pub fn validate(&self) -> Result<(), AssumptionsError> { let mut failures = Vec::new(); let typed = &self.typed; // Typo guard on fixed costs. if !(100.0 < typed.expenses.f_monthly && typed.expenses.f_monthly < 10_000.0) { failures.push(format!( "expenses.F_monthly = {} is outside (100, 10000)", typed.expenses.f_monthly )); } // Tier mix must sum to 1.0. let mix = &typed.tier_mix.assumed; let mix_sum = mix.basic_pct + mix.small_files_pct + mix.big_files_pct + mix.everything_pct; if (mix_sum - 1.0).abs() > 1e-6 { failures.push(format!("tier_mix.assumed sums to {mix_sum}, expected 1.0")); } // Surplus split must sum to 1.0. let split_sum = typed.reserve.surplus_split_reserve + typed.reserve.surplus_split_earnback; if (split_sum - 1.0).abs() > 1e-6 { failures.push(format!( "reserve.surplus_split_{{reserve,earnback}} sums to {split_sum}, expected 1.0" )); } // Rho bounds. if !(0.0 < typed.reserve.rho_annual && typed.reserve.rho_annual <= 1.0) { failures.push(format!( "reserve.rho_annual = {} is outside (0, 1]", typed.reserve.rho_annual )); } if !(0.0 < typed.reserve.rho_incident && typed.reserve.rho_incident <= 1.0) { failures.push(format!( "reserve.rho_incident = {} is outside (0, 1]", typed.reserve.rho_incident )); } if typed.reserve.rho_incident > typed.reserve.rho_annual { failures.push(format!( "reserve.rho_incident ({}) > reserve.rho_annual ({})", typed.reserve.rho_incident, typed.reserve.rho_annual )); } // Founding ≤ standard for every tier. let founding = &typed.tiers.founding; let standard = &typed.tiers.standard; for (name, fv, sv) in [ ("basic", founding.basic, standard.basic), ("small_files", founding.small_files, standard.small_files), ("big_files", founding.big_files, standard.big_files), ("everything", founding.everything, standard.everything), ] { if fv > sv { failures.push(format!( "tiers.founding.{name} ({fv}) > tiers.standard.{name} ({sv})" )); } } // Cohort caps positive. if typed.cohort.cap_count <= 0 { failures.push(format!( "cohort.cap_count = {} must be > 0", typed.cohort.cap_count )); } if typed.cohort.cap_months <= 0 { failures.push(format!( "cohort.cap_months = {} must be > 0", typed.cohort.cap_months )); } // tier_bytes. must match the parsed tier_limits. display string. // Drift here would let a docs edit ("10 MB → 20 MB") ship without the // upload gate agreeing, or vice versa. Binary units (KB = 1024 B). let limits = &typed.tier_limits; let bytes = &typed.tier_bytes; for (name, disp, byt) in [ ( "basic_per_file", &limits.basic_per_file, bytes.basic_per_file, ), ("basic_total", &limits.basic_total, bytes.basic_total), ( "small_files_per_file", &limits.small_files_per_file, bytes.small_files_per_file, ), ( "small_files_total", &limits.small_files_total, bytes.small_files_total, ), ( "big_files_per_file", &limits.big_files_per_file, bytes.big_files_per_file, ), ( "big_files_total", &limits.big_files_total, bytes.big_files_total, ), ( "everything_per_file", &limits.everything_per_file, bytes.everything_per_file, ), ( "everything_total", &limits.everything_total, bytes.everything_total, ), ] { match parse_size_bytes(disp) { Ok(parsed) if parsed == byt => {} Ok(parsed) => failures.push(format!( "tier_limits.{name} = {disp:?} parses to {parsed} bytes, \ but tier_bytes.{name} = {byt}" )), Err(e) => failures.push(format!( "tier_limits.{name} = {disp:?} could not be parsed: {e}" )), } } if failures.is_empty() { Ok(()) } else { Err(AssumptionsError::Validation(failures)) } } /// Substitute `{{ dotted.path }}` placeholders in markdown. /// /// Returns `Err(Substitution)` listing every key that could not be /// resolved. The output is the markdown with all resolved keys replaced; /// unresolved keys are left in place when an error is returned, so callers /// can grep for them. pub fn substitute(&self, markdown: &str) -> Result { Ok(self.subst.substitute(markdown)?) } /// Look up a single key. Useful for testing and for programmatic callers /// that don't want to go through markdown substitution. pub fn get(&self, key: &str) -> Option<&LookupValue> { self.subst.get(key) } /// Iterate over every available key (raw + derived) in arbitrary order. pub fn keys(&self) -> impl Iterator { self.subst.keys() } } // --- typed mirror of assumptions.toml // // Only the fields needed for validation and derived values. Unknown fields // (e.g. `[expenses.lines]`, `[stripe.connect_express]`) are silently ignored // by serde but still reach the lookup table through `walk_value`. #[derive(Debug, Deserialize)] struct Typed { expenses: TExpenses, stripe: TStripe, tiers: TTiers, tier_mix: TTierMix, tier_limits: TTierLimits, tier_bytes: TTierBytes, reserve: TReserve, cohort: TCohort, creator_marginal: TCreatorMarginal, annual_discount: TAnnualDiscount, } #[derive(Debug, Deserialize)] struct TTierLimits { basic_per_file: String, basic_total: String, small_files_per_file: String, small_files_total: String, big_files_per_file: String, big_files_total: String, everything_per_file: String, everything_total: String, } #[derive(Debug, Deserialize)] struct TTierBytes { basic_per_file: i64, basic_total: i64, small_files_per_file: i64, small_files_total: i64, big_files_per_file: i64, big_files_total: i64, everything_per_file: i64, everything_total: i64, } #[derive(Debug, Deserialize)] struct TExpenses { #[serde(rename = "F_monthly")] f_monthly: f64, } #[derive(Debug, Deserialize)] struct TStripe { percent: f64, fixed: f64, dispute_fee: f64, } #[derive(Debug, Deserialize)] struct TTiers { founding: TTierPrices, standard: TTierPrices, } #[derive(Debug, Deserialize)] struct TTierPrices { basic: f64, small_files: f64, big_files: f64, everything: f64, } #[derive(Debug, Deserialize)] struct TTierMix { assumed: TMixWeights, } // The `_pct` suffix is the serde wire format: these names are the TOML keys the // assumptions file is written with, so renaming them is a breaking change to the // document format, not a refactor. #[allow(clippy::struct_field_names)] #[derive(Debug, Deserialize)] struct TMixWeights { basic_pct: f64, small_files_pct: f64, big_files_pct: f64, everything_pct: f64, } #[derive(Debug, Deserialize)] struct TReserve { #[serde(rename = "T_fixed_months")] t_fixed_months: f64, #[serde(rename = "S_legal")] s_legal: f64, #[serde(rename = "S_shock")] s_shock: f64, #[serde(rename = "R_opp")] r_opp: f64, rho_annual: f64, rho_incident: f64, surplus_split_reserve: f64, surplus_split_earnback: f64, } #[derive(Debug, Deserialize)] struct TCohort { cap_count: i64, cap_months: i64, } #[derive(Debug, Deserialize)] struct TAnnualDiscount { multiplier: f64, } #[derive(Debug, Deserialize)] struct TCreatorMarginal { storage_basic_gb: f64, storage_small_files_gb: f64, storage_big_files_gb: f64, storage_everything_gb: f64, storage_cost_per_gb_per_month: f64, chargeback_rate_tier_subs: f64, } // --- walking + derived /// Parse a size display string ("10MB", "500GB") to bytes. Binary units /// (KB = 1024 B). Accepts a decimal number and a case-insensitive suffix /// with no space between them, matching the `[tier_limits]` convention. fn parse_size_bytes(s: &str) -> Result { let s = s.trim(); let split = s .find(|c: char| c.is_ascii_alphabetic()) .ok_or_else(|| format!("no unit suffix in {s:?}"))?; let (num, unit) = s.split_at(split); let num: f64 = num .trim() .parse() .map_err(|e| format!("bad number in {s:?}: {e}"))?; let mult: f64 = match unit.trim().to_ascii_uppercase().as_str() { "B" => 1.0, "KB" => 1024.0, "MB" => 1024.0 * 1024.0, "GB" => 1024.0 * 1024.0 * 1024.0, "TB" => 1024.0_f64.powi(4), other => return Err(format!("unknown unit {other:?} in {s:?}")), }; Ok((num * mult).round() as i64) } fn walk_value(value: &toml::Value, prefix: String, out: &mut HashMap) { match value { toml::Value::Table(table) => { for (k, v) in table { let key = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; walk_value(v, key, out); } } toml::Value::Integer(n) => { out.insert(prefix, Value::Int(*n)); } toml::Value::Float(x) => { out.insert(prefix, Value::Float(*x)); } toml::Value::String(s) => { out.insert(prefix, Value::String(s.clone())); } // Bools, arrays, datetimes: not substitutable. toml::Value::Boolean(_) | toml::Value::Array(_) | toml::Value::Datetime(_) => {} } } fn insert_derived(t: &Typed, out: &mut HashMap) { let mut put = |k: &str, v: f64| { out.insert(format!("derived.{k}"), Value::Float(v)); }; let r_cap = t.reserve.t_fixed_months * t.expenses.f_monthly + t.reserve.s_legal + t.reserve.s_shock; put("R_cap", r_cap); // ARPU per rate class. let mix = &t.tier_mix.assumed; let arpu_founding = mix.basic_pct * t.tiers.founding.basic + mix.small_files_pct * t.tiers.founding.small_files + mix.big_files_pct * t.tiers.founding.big_files + mix.everything_pct * t.tiers.founding.everything; let arpu_standard = mix.basic_pct * t.tiers.standard.basic + mix.small_files_pct * t.tiers.standard.small_files + mix.big_files_pct * t.tiers.standard.big_files + mix.everything_pct * t.tiers.standard.everything; put("ARPU_founding", arpu_founding); put("ARPU_standard", arpu_standard); // Stripe fee per tier price (creator-facing examples — these are what the // creator pays Stripe on their fan transactions, NOT what MNW pays Stripe). let stripe_fee = |amt: f64| t.stripe.percent * amt + t.stripe.fixed; put("stripe_fee_basic_std", stripe_fee(t.tiers.standard.basic)); put( "stripe_fee_small_std", stripe_fee(t.tiers.standard.small_files), ); put("stripe_fee_big_std", stripe_fee(t.tiers.standard.big_files)); put("stripe_fee_ev_std", stripe_fee(t.tiers.standard.everything)); // Stripe fee + take-home on illustrative sale prices ($1, $2, $5, $10, $25, // $50). Pin the "sale price → fee → you keep" tables in guide/tiers.md and // guide/stripe.md. Values are pre-rounded to whole cents so the `money` // filter's `:.2` format won't drift from float imprecision (e.g. $25 × 2.9% // + $0.30 = 1.02499999… under f64, which naive :.2 would print as "$1.02"). // "you keep" is `sale − round(fee)` — arithmetically consistent with what a // reader would compute from the fee column, at the cost of one-cent drift on // $25 relative to the original hand-written doc ($23.97, not $23.98). let round_cents = |x: f64| (x * 100.0).round() / 100.0; for &n in &[1.0_f64, 2.0, 5.0, 10.0, 25.0, 50.0] { let key = n as i64; let fee = round_cents(stripe_fee(n)); put(&format!("stripe_fee_on_{key}"), fee); put(&format!("stripe_keep_on_{key}"), n - fee); } // Discount percentages as decimals. Render with `| percent(0)` for whole // numbers (e.g. `10%`, `50%`). `annual_discount_pct` kills the hardcoded // "10% off" in pricing.md and tiers.md; `founder_discount_pct` kills the // "50%-off" in guarantees.md. Founder discount uses the Basic tier as the // canonical ratio; all four tiers currently give the same discount and // that invariant is enforced elsewhere by the founding ≤ standard check. put("annual_discount_pct", 1.0 - t.annual_discount.multiplier); put( "founder_discount_pct", 1.0 - t.tiers.founding.basic / t.tiers.standard.basic, ); // Annual prices per tier (monthly × 12 × annual_discount.multiplier, rounded // to nearest dollar). Substituted into docs as `${{ derived.annual_*_* }}` // so a price change auto-propagates. let yr = |monthly: f64| (monthly * 12.0 * t.annual_discount.multiplier).round(); put("annual_founding_basic", yr(t.tiers.founding.basic)); put( "annual_founding_small_files", yr(t.tiers.founding.small_files), ); put("annual_founding_big_files", yr(t.tiers.founding.big_files)); put( "annual_founding_everything", yr(t.tiers.founding.everything), ); put("annual_standard_basic", yr(t.tiers.standard.basic)); put( "annual_standard_small_files", yr(t.tiers.standard.small_files), ); put("annual_standard_big_files", yr(t.tiers.standard.big_files)); put( "annual_standard_everything", yr(t.tiers.standard.everything), ); // --- marginal cost per creator/month, broken down by component // // What MNW pays per active creator on top of fixed costs F: // // storage : weighted GB × $/GB/month — Hetzner object storage. // stripe_sub : Stripe processing fee on the creator's tier subscription // (creator→MNW). Weighted across the tier mix. Stripe fees // on fan→creator transactions are $0 to MNW (Connect Std). // chargeback : Expected dispute fee per sub/month (small but real). // // Components are emitted individually so docs can show a breakdown table. // `marginal_avg_{standard,founding}` is the sum per rate class. // // NOT modeled (deliberately): egress (at current scale, within Hetzner's // 20 TB/server free allowance), support time (unmeasured pre-launch — see // A26 in assumptions.md). let m = &t.creator_marginal; let marginal_storage = (mix.basic_pct * m.storage_basic_gb + mix.small_files_pct * m.storage_small_files_gb + mix.big_files_pct * m.storage_big_files_gb + mix.everything_pct * m.storage_everything_gb) * m.storage_cost_per_gb_per_month; put("marginal_storage", marginal_storage); let weighted_stripe_sub = |tiers: &TTierPrices| { mix.basic_pct * stripe_fee(tiers.basic) + mix.small_files_pct * stripe_fee(tiers.small_files) + mix.big_files_pct * stripe_fee(tiers.big_files) + mix.everything_pct * stripe_fee(tiers.everything) }; let marginal_stripe_standard = weighted_stripe_sub(&t.tiers.standard); let marginal_stripe_founding = weighted_stripe_sub(&t.tiers.founding); put("marginal_stripe_standard", marginal_stripe_standard); put("marginal_stripe_founding", marginal_stripe_founding); // Chargeback expected value: rate × dispute fee. The lost-revenue portion // (disputed charge amount) is treated as a refund of the original sub // payment, not a separate cost — it flows through ARPU naturally if rates // are accurate. Only the $15 dispute fee is incremental. let marginal_chargeback = m.chargeback_rate_tier_subs * t.stripe.dispute_fee; put("marginal_chargeback", marginal_chargeback); let marginal_avg_standard = marginal_storage + marginal_stripe_standard + marginal_chargeback; let marginal_avg_founding = marginal_storage + marginal_stripe_founding + marginal_chargeback; put("marginal_avg_standard", marginal_avg_standard); put("marginal_avg_founding", marginal_avg_founding); // Back-compat alias for the previous single-value marginal. put("marginal_avg", marginal_avg_standard); // Break-even creator counts (per rate class). let break_even_standard = t.expenses.f_monthly / (arpu_standard - marginal_avg_standard); let break_even_founding = t.expenses.f_monthly / (arpu_founding - marginal_avg_founding); put("break_even_standard", break_even_standard); put("break_even_founding", break_even_founding); // Surplus at representative cohort sizes (per rate class, using the matching marginal). let surplus = |n: f64, arpu: f64, marg: f64| n * (arpu - marg) - t.expenses.f_monthly; put( "surplus_100_standard", surplus(100.0, arpu_standard, marginal_avg_standard), ); put( "surplus_500_standard", surplus(500.0, arpu_standard, marginal_avg_standard), ); put( "surplus_100_founding", surplus(100.0, arpu_founding, marginal_avg_founding), ); put( "surplus_500_founding", surplus(500.0, arpu_founding, marginal_avg_founding), ); // Fill-time in months to reach R_cap + R_opp at representative cohort sizes // under the standard rate. let target = r_cap + t.reserve.r_opp; let fill_time = |n: f64| target / surplus(n, arpu_standard, marginal_avg_standard); put("fill_time_100", fill_time(100.0)); put("fill_time_500", fill_time(500.0)); } // --- tests #[cfg(test)] mod tests { use super::*; // Vendored copy of the canonical assumptions.toml (the source of truth // lives in the MNW server repo at server/docs/business/assumptions.toml). // Kept in-crate so this crate builds and tests in isolation, without // reaching across repos. Refresh with: // cp ../../server/docs/business/assumptions.toml tests/fixtures/ const FIXTURE: &str = include_str!("../tests/fixtures/assumptions.toml"); fn loaded() -> Assumptions { Assumptions::parse(FIXTURE).expect("fixture parses") } #[test] fn fixture_loads_and_validates() { let a = loaded(); a.validate().expect("fixture validates"); } #[test] fn raw_lookup_returns_int_and_float_variants() { let a = loaded(); assert_eq!(a.get("expenses.F_monthly"), Some(&LookupValue::Int(580))); assert_eq!(a.get("stripe.percent"), Some(&LookupValue::Float(0.029))); assert_eq!( a.get("cohort.lock_duration"), Some(&LookupValue::String("lifetime".into())) ); } #[test] fn derived_values_match_worked_examples() { let a = loaded(); let get_f = |k: &str| match a.get(k).unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float at {k}, got {v:?}"), }; let get_i = |k: &str| match a.get(k).unwrap() { LookupValue::Int(n) => *n as f64, LookupValue::Float(x) => *x, v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"), }; // R_cap = T_fixed_months · F_monthly + S_legal + S_shock — derived // from the same toml the test loads, so a price/reserve edit doesn't // force this test to be rewritten. let f_monthly = get_i("expenses.F_monthly"); let t_fixed = get_i("reserve.T_fixed_months"); let s_legal = get_i("reserve.S_legal"); let s_shock = get_i("reserve.S_shock"); let r_cap = get_f("derived.R_cap"); let expected_r_cap = t_fixed * f_monthly + s_legal + s_shock; assert!( (r_cap - expected_r_cap).abs() < 1e-6, "R_cap {r_cap} != {expected_r_cap}" ); // ARPU_standard = Σ (mix._pct × standard.). let mix_basic = get_f("tier_mix.assumed.basic_pct"); let mix_small = get_f("tier_mix.assumed.small_files_pct"); let mix_big = get_f("tier_mix.assumed.big_files_pct"); let mix_ev = get_f("tier_mix.assumed.everything_pct"); let expected_arpu = mix_basic * get_i("tiers.standard.basic") + mix_small * get_i("tiers.standard.small_files") + mix_big * get_i("tiers.standard.big_files") + mix_ev * get_i("tiers.standard.everything"); let arpu = get_f("derived.ARPU_standard"); assert!( (arpu - expected_arpu).abs() < 1e-9, "ARPU {arpu} != {expected_arpu}" ); // stripe_fee_basic_std = stripe.percent × price + stripe.fixed. let expected_fee = get_f("stripe.percent") * get_i("tiers.standard.basic") + get_f("stripe.fixed"); let fee = get_f("derived.stripe_fee_basic_std"); assert!( (fee - expected_fee).abs() < 1e-9, "stripe_fee_basic_std {fee} != {expected_fee}" ); } #[test] fn substitute_replaces_known_keys() { let a = loaded(); let out = a .substitute("Fixed monthly costs are ${{ expenses.F_monthly }}.") .unwrap(); assert_eq!(out, "Fixed monthly costs are $580."); } #[test] fn substitute_replaces_derived_keys() { let a = loaded(); let out = a.substitute("R_cap = ${{ derived.R_cap }}").unwrap(); assert_eq!(out, "R_cap = $61960"); } #[test] fn substitute_handles_whitespace_in_markers() { let a = loaded(); let out = a .substitute("a={{expenses.F_monthly}} b={{ expenses.F_monthly }}") .unwrap(); assert_eq!(out, "a=580 b=580"); } #[test] fn marginal_cost_components_decomposition() { let a = loaded(); let get_f = |k: &str| match a.get(k).unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float at {k}, got {v:?}"), }; let get_i = |k: &str| match a.get(k).unwrap() { LookupValue::Int(n) => *n as f64, LookupValue::Float(x) => *x, v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"), }; // Structural: marginal_avg_standard = storage + stripe + chargeback. // The individual sub-lines are computed from the toml, so verifying // the sum invariant is the useful assertion — pinning literal values // would just re-encode a Refined-A tier mix. let storage = get_f("derived.marginal_storage"); let stripe = get_f("derived.marginal_stripe_standard"); let chargeback = get_f("derived.marginal_chargeback"); let avg = get_f("derived.marginal_avg_standard"); assert!( (avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}" ); // Chargeback formula: rate × dispute_fee. Pinned by the two inputs. let expected_chargeback = get_f("creator_marginal.chargeback_rate_tier_subs") * get_f("stripe.dispute_fee"); assert!( (chargeback - expected_chargeback).abs() < 1e-9, "chargeback {chargeback} != rate × dispute {expected_chargeback}" ); // Founding Stripe fee ≤ standard (lower prices → lower % component). let stripe_f = get_f("derived.marginal_stripe_founding"); assert!( stripe_f <= stripe, "founding stripe {stripe_f} should be ≤ standard {stripe}" ); // Storage weighted by the assumed tier mix (all inputs from toml). let expected_storage = (get_f("tier_mix.assumed.basic_pct") * get_f("creator_marginal.storage_basic_gb") + get_f("tier_mix.assumed.small_files_pct") * get_i("creator_marginal.storage_small_files_gb") + get_f("tier_mix.assumed.big_files_pct") * get_i("creator_marginal.storage_big_files_gb") + get_f("tier_mix.assumed.everything_pct") * get_i("creator_marginal.storage_everything_gb")) * get_f("creator_marginal.storage_cost_per_gb_per_month"); assert!( (storage - expected_storage).abs() < 1e-9, "storage {storage} != {expected_storage}" ); } #[test] fn derived_stripe_fee_on_n_matches_published_table() { let a = loaded(); let get_money = |k: &str| { a.substitute(&format!("{{{{ derived.{k} | money }}}}")) .unwrap() }; // These strings must match the "Stripe fee → You keep" table in // guide/tiers.md line 138-143 and guide/stripe.md exactly, or docs drift. // Sale-price-based (not tier-price-based), so stable under Refined-A. assert_eq!(get_money("stripe_fee_on_1"), "$0.33"); assert_eq!(get_money("stripe_fee_on_2"), "$0.36"); assert_eq!(get_money("stripe_fee_on_5"), "$0.45"); assert_eq!(get_money("stripe_fee_on_10"), "$0.59"); assert_eq!(get_money("stripe_fee_on_25"), "$1.03"); assert_eq!(get_money("stripe_fee_on_50"), "$1.75"); assert_eq!(get_money("stripe_keep_on_1"), "$0.67"); assert_eq!(get_money("stripe_keep_on_2"), "$1.64"); assert_eq!(get_money("stripe_keep_on_5"), "$4.55"); assert_eq!(get_money("stripe_keep_on_10"), "$9.41"); // The keep column is price minus the rounded fee, $25 - round($1.025), // not round($25 - $1.025). Consistent with the fee column. assert_eq!(get_money("stripe_keep_on_25"), "$23.97"); assert_eq!(get_money("stripe_keep_on_50"), "$48.25"); } #[test] fn derived_discount_pcts_render_as_whole_percent() { let a = loaded(); // percent(0) renders the whole-percent form used in the docs. Both are // stable under Refined-A: annual discount is 10%, founder is 50% of std. assert_eq!( a.substitute("{{ derived.annual_discount_pct | percent(0) }}") .unwrap(), "10%" ); assert_eq!( a.substitute("{{ derived.founder_discount_pct | percent(0) }}") .unwrap(), "50%" ); } // Both sides of the price assertion are whole dollars out of `.round()`, // so exact equality is the assertion, not an epsilon comparison. #[allow(clippy::float_cmp)] #[test] fn derived_annual_prices_match_monthly_times_discount() { // Formula: monthly × 12 × annual_discount.multiplier, rounded to // the nearest whole dollar. All four tiers × two rate classes. let a = loaded(); let get_f = |k: &str| match a.get(k).unwrap() { LookupValue::Float(x) => *x, v => panic!("expected float at {k}, got {v:?}"), }; let get_i = |k: &str| match a.get(k).unwrap() { LookupValue::Int(n) => *n as f64, LookupValue::Float(x) => *x, v @ LookupValue::String(_) => panic!("expected number at {k}, got {v:?}"), }; let mult = get_f("annual_discount.multiplier"); for tier in ["basic", "small_files", "big_files", "everything"] { for class in ["founding", "standard"] { let monthly = get_i(&format!("tiers.{class}.{tier}")); let expected = (monthly * 12.0 * mult).round(); let actual = get_f(&format!("derived.annual_{class}_{tier}")); assert_eq!( actual, expected, "annual_{class}_{tier}: {actual} != round({monthly} × 12 × {mult}) = {expected}" ); } } } #[test] fn substitute_applies_ceil_filter_to_derived_value() { let a = loaded(); let out = a .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.") .unwrap(); // The numeric value drifts with any pricing change, so pin the *shape* // (integer followed by "creators.") rather than a specific N. assert!( out.starts_with("Break-even at ~") && out.ends_with(" creators."), "unexpected shape: {out:?}" ); let n_str = out .trim_start_matches("Break-even at ~") .trim_end_matches(" creators."); let n: i64 = n_str.parse().expect("ceil should render as an integer"); assert!(n > 0, "break-even should be positive, got {n}"); } #[test] fn substitute_applies_percent_filter() { let a = loaded(); let out = a .substitute("Stripe charges {{ stripe.percent | percent }}.") .unwrap(); assert_eq!(out, "Stripe charges 2.9%."); } #[test] fn substitute_applies_money_filter() { let a = loaded(); let out = a .substitute("Flat fee: {{ stripe.fixed | money }}.") .unwrap(); assert_eq!(out, "Flat fee: $0.30."); } #[test] fn substitute_chains_filters() { let a = loaded(); let out = a .substitute("{{ derived.break_even_standard | round(1) }}") .unwrap(); // round(1) produces a single-decimal string. Verify the shape // rather than a specific value (which changes when prices change). let dot = out .find('.') .expect("round(1) should include a decimal point"); assert_eq!( out.len() - dot - 1, 1, "should have exactly one decimal digit, got {out:?}" ); let _: f64 = out.parse().expect("should parse as float"); } #[test] fn substitute_consumer_can_register_custom_filter() { // Closure-based filter: format thousands as "Nk". let a = loaded().with_filter("kilo", |v: LookupValue, _args: &[FilterArg]| { let n = v .as_f64() .ok_or_else(|| FilterError::type_error("kilo", &v))?; Ok(LookupValue::String(format!("{:.1}k", n / 1000.0))) }); let out = a.substitute("Cap: {{ derived.R_cap | kilo }}").unwrap(); assert_eq!(out, "Cap: 62.0k"); } #[test] fn substitute_unknown_filter_reports_error() { let a = loaded(); let err = a.substitute("{{ expenses.F_monthly | nope }}").unwrap_err(); match err { AssumptionsError::Substitution { unresolved } => { assert!( unresolved.iter().any(|m| m.contains("unknown filter")), "{unresolved:?}" ); } other => panic!("{other:?}"), } } #[test] fn substitute_filter_type_mismatch_reports_error() { let a = loaded(); let err = a .substitute("{{ cohort.lock_duration | money }}") .unwrap_err(); assert!(matches!(err, AssumptionsError::Substitution { .. })); } #[test] fn substitute_skips_inline_code() { let a = loaded(); let out = a .substitute("Real: {{ expenses.F_monthly }}. Literal: `{{ template.placeholder }}`.") .unwrap(); assert_eq!(out, "Real: 580. Literal: `{{ template.placeholder }}`."); } #[test] fn substitute_skips_fenced_code_block() { let a = loaded(); let input = "Value: {{ expenses.F_monthly }}\n\n```\nUse {{ tauri.placeholder }}\n```\n"; let out = a.substitute(input).unwrap(); assert!(out.contains("Value: 580")); assert!(out.contains("{{ tauri.placeholder }}"), "got: {out}"); } #[test] fn substitute_reports_unresolved_keys() { let a = loaded(); let err = a .substitute("{{ nope.absent }} and {{ also.missing }} and {{ nope.absent }}") .unwrap_err(); match err { AssumptionsError::Substitution { unresolved } => { assert_eq!( unresolved, vec!["also.missing".to_string(), "nope.absent".to_string()] ); } other => panic!("expected Substitution, got {other:?}"), } } #[test] fn validation_catches_f_monthly_out_of_range() { let t = FIXTURE.replace("F_monthly = 580", "F_monthly = 50"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("F_monthly")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_tier_mix_sum_off() { let t = FIXTURE.replace("basic_pct = 0.40", "basic_pct = 0.50"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("tier_mix")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_founding_above_standard() { // Rewrite `[tiers.founding]` so basic > standard.basic. Using a // section-anchored search keeps this test working regardless of the // specific dollar figure the standard tier is set to. let re = regex_lite::Regex::new(r"(?m)^\[tiers\.founding\]\n((?:.*\n)*?)basic = \d+").unwrap(); let t = re .replace(FIXTURE, |caps: ®ex_lite::Captures| { format!("[tiers.founding]\n{}basic = 99999", &caps[1]) }) .into_owned(); assert!(t != FIXTURE, "regex must have matched"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("founding.basic")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn parse_size_bytes_binary_units() { // No space, case-insensitive suffix, binary units. assert_eq!(parse_size_bytes("10MB").unwrap(), 10 * 1024 * 1024); assert_eq!(parse_size_bytes("50GB").unwrap(), 50 * 1024 * 1024 * 1024); assert_eq!(parse_size_bytes("500MB").unwrap(), 500 * 1024 * 1024); assert_eq!( parse_size_bytes("250GB").unwrap(), 250i64 * 1024 * 1024 * 1024 ); assert_eq!( parse_size_bytes("500GB").unwrap(), 500i64 * 1024 * 1024 * 1024 ); assert_eq!( parse_size_bytes("20GB").unwrap(), 20i64 * 1024 * 1024 * 1024 ); assert_eq!(parse_size_bytes("1KB").unwrap(), 1024); assert_eq!(parse_size_bytes("1B").unwrap(), 1); // Case-insensitive. assert_eq!(parse_size_bytes("10mb").unwrap(), 10 * 1024 * 1024); // Space between number and unit is tolerated (parser trims), even // though the [tier_limits] convention writes "10MB" with no space. assert_eq!(parse_size_bytes("10 MB").unwrap(), 10 * 1024 * 1024); // Rejects malformed input. assert!(parse_size_bytes("10ZB").is_err(), "unknown unit"); assert!(parse_size_bytes("MB").is_err(), "missing number"); } #[test] fn validate_catches_tier_bytes_display_drift() { // Rewrite `basic_per_file` from its current display value to "999GB" // while leaving `tier_bytes.basic_per_file` alone — validator should // catch the mismatch. Using a fresh regex is more robust than // hardcoding whatever the current display string happens to be. let re = regex_lite::Regex::new(r#"basic_per_file = "[^"]+""#).unwrap(); let broken = re .replace(FIXTURE, r#"basic_per_file = "999GB""#) .into_owned(); assert!(broken != FIXTURE, "regex must have matched something"); let a = Assumptions::parse(&broken).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(rules) => { assert!( rules .iter() .any(|r| r.contains("tier_limits.basic_per_file") && r.contains("tier_bytes.basic_per_file")), "expected drift failure, got: {rules:?}" ); } other => panic!("expected Validation, got {other:?}"), } } #[test] fn validate_accepts_canonical_tier_bytes_pairing() { let a = loaded(); // Guards that the canonical fixture's tier_bytes match tier_limits. a.validate() .expect("canonical fixture: tier_bytes must match tier_limits"); } #[test] fn validation_catches_surplus_split_off() { let t = FIXTURE.replace( "surplus_split_reserve = 0.20", "surplus_split_reserve = 0.30", ); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("surplus_split")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn validation_catches_rho_incident_above_annual() { let t = FIXTURE.replace("rho_incident = 0.25", "rho_incident = 0.75"); let a = Assumptions::parse(&t).unwrap(); let err = a.validate().unwrap_err(); match err { AssumptionsError::Validation(v) => { assert!(v.iter().any(|m| m.contains("rho_incident")), "got: {v:?}"); } other => panic!("{other:?}"), } } #[test] fn unknown_fields_are_ignored() { // Unknown sections shouldn't break load; they just won't appear in the // typed view but should still be in the flat lookup. let extra = format!("{FIXTURE}\n[extra_section]\nnew_key = 42\n"); let a = Assumptions::parse(&extra).unwrap(); assert_eq!(a.get("extra_section.new_key"), Some(&LookupValue::Int(42))); } #[test] fn load_from_path_round_trip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("a.toml"); std::fs::write(&path, FIXTURE).unwrap(); let a = Assumptions::load(&path).unwrap(); a.validate().unwrap(); assert!(a.get("derived.R_cap").is_some()); } }