//! Public `/pricing` comparison model, the single source of truth for the //! "you keep more with MNW" claim. //! //! The fee constants and every competitor's parameters live in //! `docs/business/assumptions.toml` (`[stripe]`, `[pricing_calc]`, //! `[[competitors]]`) and are parsed here at startup. This replaces the old //! `static/page-pricing.js`, which hardcoded the Stripe rate and nine //! competitor fee models client-side with no server backing and no test, a //! public marketing claim that could silently drift from the real checkout //! rate. The page now recomputes server-side via a debounced HTMX request; the //! math never runs in the browser. //! //! Missing/malformed sections panic at startup (same contract as //! `TierPrices::from_assumptions`): production never serves a half-loaded //! comparison. The per-competitor formula is pinned by `tests` below to the //! exact values the retired JS produced. use std::path::Path; use serde::Deserialize; /// Stripe fee model, read from the `[stripe]` block. Extra keys in that block /// (dispute fees, payout rates, connect sub-tables) are ignored. #[derive(Debug, Clone, Deserialize)] pub struct StripeFees { pub percent: f64, pub fixed: f64, } /// Calculator tunables from `[pricing_calc]`. #[derive(Debug, Clone, Deserialize)] pub struct PricingCalc { /// Assumed average sale, used to estimate per-transaction fee count. pub avg_transaction: f64, } /// How a competitor layers payment processing on top of its platform cut. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StripeMode { /// Processing is included in the platform cut (e.g. Lemon Squeezy). None, /// Adds the percent fee on the post-platform amount (no fixed per-tx). Percent, /// Adds both the percent fee and the fixed per-transaction fee. PercentAndFixed, } /// One competitor's fee parameters. All numeric knobs default to zero/none so a /// simple percentage-only platform needs just `platform_pct` + `stripe_mode`. #[derive(Debug, Clone, Deserialize)] pub struct Competitor { pub name: String, pub url: String, pub fee_label: String, #[serde(default)] pub platform_pct: f64, /// Reduced rate above `platform_pct_threshold` (Bandcamp: 10% over $5k). #[serde(default)] pub platform_pct_over: Option, #[serde(default)] pub platform_pct_threshold: Option, /// Flat per-transaction platform fee, applied before processing (Gumroad, /// Lemon Squeezy: $0.50). #[serde(default)] pub platform_per_tx: f64, /// Flat monthly platform charge (Ko-fi Gold: $12). #[serde(default)] pub flat_monthly: f64, pub stripe_mode: StripeMode, } /// Loaded pricing model. Built once at startup, held in `AppState`. #[derive(Debug, Clone)] pub struct PricingComparison { stripe: StripeFees, calc: PricingCalc, competitors: Vec, } /// Shape of the subset of `assumptions.toml` this module cares about. Serde /// ignores every other section, so the whole file deserializes cleanly. #[derive(Deserialize)] struct PricingToml { stripe: StripeFees, pricing_calc: PricingCalc, #[serde(default)] competitors: Vec, } impl PricingComparison { /// Parse the pricing model from the assumptions TOML at `path`. /// /// Panics on a missing file or a malformed `[stripe]`/`[pricing_calc]` /// section, the same startup contract as the other assumptions loaders. pub fn load>(path: P) -> Self { let text = std::fs::read_to_string(path.as_ref()).unwrap_or_else(|e| { panic!( "failed to read assumptions for pricing comparison from {}: {e}", path.as_ref().display() ) }); Self::parse(&text) } /// Parse from a TOML string. Split out for testing. pub fn parse(text: &str) -> Self { let t: PricingToml = toml::from_str(text).unwrap_or_else(|e| { panic!("failed to parse pricing sections of assumptions.toml: {e}") }); Self { stripe: t.stripe, calc: t.pricing_calc, competitors: t.competitors, } } /// Estimated number of transactions to clear `revenue` at the assumed /// average sale. Zero for non-positive revenue; otherwise at least one. fn estimate_transactions(&self, revenue: f64) -> f64 { if revenue <= 0.0 { return 0.0; } (revenue / self.calc.avg_transaction).round().max(1.0) } /// What a creator keeps on MNW's processing alone (before membership). fn stripe_net(&self, revenue: f64) -> f64 { if revenue <= 0.0 { return 0.0; } let txns = self.estimate_transactions(revenue); revenue - revenue * self.stripe.percent - txns * self.stripe.fixed } /// Net revenue a creator keeps on a competitor at `revenue`. fn competitor_net(&self, c: &Competitor, revenue: f64) -> f64 { if revenue <= 0.0 { return -c.flat_monthly; } let txns = self.estimate_transactions(revenue); let pct = match (c.platform_pct_over, c.platform_pct_threshold) { (Some(over), Some(threshold)) if revenue > threshold => over, _ => c.platform_pct, }; let after_platform = revenue * (1.0 - pct) - txns * c.platform_per_tx; let after_processing = match c.stripe_mode { StripeMode::None => after_platform, StripeMode::Percent => after_platform - after_platform * self.stripe.percent, StripeMode::PercentAndFixed => { after_platform - after_platform * self.stripe.percent - txns * self.stripe.fixed } }; after_processing - c.flat_monthly } /// Build the full comparison for a given monthly `revenue` and selected /// tier's monthly `tier_cost` (both in whole dollars). Rows are sorted by /// net kept, descending, with the MNW row flagged. pub fn compute(&self, revenue: f64, tier_cost: f64) -> ComparisonResult { let mnw_net = self.stripe_net(revenue) - tier_cost; struct Raw { name: String, url: String, fee_label: String, is_mnw: bool, net: f64, } let mut raws = Vec::with_capacity(self.competitors.len() + 1); raws.push(Raw { name: "Makenot.work".to_string(), url: String::new(), fee_label: format!("{}/mo flat + ~3% processing", fmt_money(tier_cost)), is_mnw: true, net: mnw_net, }); for c in &self.competitors { raws.push(Raw { name: c.name.clone(), url: c.url.clone(), fee_label: c.fee_label.clone(), is_mnw: false, net: self.competitor_net(c, revenue), }); } raws.sort_by(|a, b| { b.net .partial_cmp(&a.net) .unwrap_or(std::cmp::Ordering::Equal) }); let mut breakeven_any = false; let rows = raws .into_iter() .map(|r| { let keep = if revenue > 0.0 { fmt_money(r.net.max(0.0)) } else { "--".to_string() }; let (diff, diff_class) = if r.is_mnw { ("--".to_string(), String::new()) } else if revenue > 0.0 { let d = mnw_net - r.net; if d > 0.005 { (format!("+{}", fmt_money(d)), "savings-negative".to_string()) } else if d < -0.005 { breakeven_any = true; (fmt_money(d), "savings-positive".to_string()) } else { ("$0.00".to_string(), String::new()) } } else { ("--".to_string(), String::new()) }; ComparisonRow { name: r.name, url: r.url, fee_label: r.fee_label, is_mnw: r.is_mnw, keep, diff, diff_class, } }) .collect(); let breakeven = if breakeven_any && revenue > 0.0 { Some(format!( "At {}/mo, some platforms with percentage-only fees cost less than \ MNW's flat rate. MNW becomes cheaper as your revenue grows.", fmt_whole(revenue) )) } else { None }; ComparisonResult { mnw_keep: fmt_money(mnw_net.max(0.0)), mnw_detail: format!( "of every {} after processing fees (~3%) and {}/mo membership", fmt_whole(revenue), fmt_money(tier_cost) ), rows, breakeven, } } } /// Rendered comparison, handed to the pricing template / HTMX partial. #[derive(Debug, Clone)] pub struct ComparisonResult { /// Formatted "you keep" headline for the MNW row (never negative). pub mnw_keep: String, /// Sub-line under the headline. pub mnw_detail: String, /// All rows (MNW + competitors), sorted by net kept, descending. pub rows: Vec, /// Breakeven note when a competitor beats MNW at this revenue, else `None`. pub breakeven: Option, } /// One rendered comparison row. All display strings are preformatted so the /// template does no math. #[derive(Debug, Clone)] pub struct ComparisonRow { pub name: String, /// Empty for the MNW row (rendered as bold text, not a link). pub url: String, pub fee_label: String, pub is_mnw: bool, /// "--" when revenue is zero, else formatted dollars. pub keep: String, /// "--" / "$0.00" / "+$x" / "$-x". pub diff: String, /// CSS class for the diff cell; empty when none applies. pub diff_class: String, } /// Format dollars as `$1,234.56`, matching the retired JS `fmt`. Negative /// values render the sign after the `$` (`$-12.34`) to match exactly. fn fmt_money(n: f64) -> String { let neg = n < 0.0; let s = format!("{:.2}", n.abs()); let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00")); format!( "${}{}.{}", if neg { "-" } else { "" }, group_thousands(int_part), frac ) } /// Format dollars as a whole number with thousands separators (`$1,000`), /// matching the retired JS `fmtWhole`. fn fmt_whole(n: f64) -> String { format!("${}", group_thousands(&format!("{:.0}", n.round().abs()))) } /// Insert commas every three digits from the right into a bare integer string. fn group_thousands(digits: &str) -> String { let bytes = digits.as_bytes(); let len = bytes.len(); let mut out = String::with_capacity(len + len / 3); for (i, b) in bytes.iter().enumerate() { if i > 0 && (len - i).is_multiple_of(3) { out.push(','); } out.push(*b as char); } out } #[cfg(test)] mod tests { use super::*; const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml"; fn loaded() -> PricingComparison { PricingComparison::load(ASSUMPTIONS_PATH) } /// The canonical toml loads and carries all nine competitors. #[test] fn loads_from_canonical_assumptions() { let p = loaded(); assert_eq!(p.competitors.len(), 9, "expected 9 competitors"); assert!((p.stripe.percent - 0.029).abs() < 1e-9); assert!((p.stripe.fixed - 0.30).abs() < 1e-9); assert!((p.calc.avg_transaction - 25.0).abs() < 1e-9); } fn net_of(result: &ComparisonResult, name: &str) -> String { result .rows .iter() .find(|r| r.name == name) .unwrap_or_else(|| panic!("row {name} missing")) .keep .clone() } /// Pin the headline + every competitor's kept-amount at $1,000/mo, Basic /// tier ($16). These are the exact figures the retired page-pricing.js /// produced, the regression anchor the JS never had. #[test] fn pins_net_at_1000_basic() { let p = loaded(); let r = p.compute(1000.0, 16.0); // stripe_net(1000) = 1000 - 29 - 40*0.30 = 959; minus $16 = 943. assert_eq!(r.mnw_keep, "$943.00"); assert_eq!(net_of(&r, "Makenot.work"), "$943.00"); assert_eq!(net_of(&r, "Patreon"), "$861.90"); // 900 - 26.1 - 12 assert_eq!(net_of(&r, "Gumroad"), "$854.48"); // (900-20) - 25.52 assert_eq!(net_of(&r, "Bandcamp"), "$825.35"); // 850 - 24.65 assert_eq!(net_of(&r, "Ko-fi Gold"), "$947.00"); // 959 - 12 assert_eq!(net_of(&r, "Lemon Squeezy"), "$930.00"); // 950 - 20 } /// Rows are sorted by net kept, descending, and the MNW row is present. #[test] fn rows_sorted_descending() { let r = loaded().compute(1000.0, 16.0); assert_eq!(r.rows.len(), 10); assert!(r.rows.iter().any(|row| row.is_mnw)); // At $1,000 Ko-fi Gold ($947) beats MNW Basic ($943), so MNW is not // first and a breakeven note appears. assert!(r.breakeven.is_some()); assert_eq!(r.rows[0].name, "Ko-fi Gold"); } /// Bandcamp's rate drops from 15% to 10% above the $5k threshold. #[test] fn bandcamp_tier_switch_at_threshold() { let p = loaded(); // At $4,000 (below): 15%. ap = 3400; net = 3400 - 3400*0.029 = 3301.40 assert_eq!(net_of(&p.compute(4000.0, 16.0), "Bandcamp"), "$3,301.40"); // At $6,000 (above): 10%. ap = 5400; net = 5400 - 5400*0.029 = 5243.40 assert_eq!(net_of(&p.compute(6000.0, 16.0), "Bandcamp"), "$5,243.40"); } /// Zero revenue renders placeholders, not computed money. #[test] fn zero_revenue_is_placeholder() { let r = loaded().compute(0.0, 16.0); assert_eq!(r.mnw_keep, "$0.00"); assert!(r.rows.iter().all(|row| row.keep == "--")); assert!(r.breakeven.is_none()); } #[test] fn money_formatting_matches_js() { assert_eq!(fmt_money(943.0), "$943.00"); assert_eq!(fmt_money(1234.5), "$1,234.50"); assert_eq!(fmt_money(-12.34), "$-12.34"); assert_eq!(fmt_whole(1000.0), "$1,000"); assert_eq!(fmt_whole(5243.4), "$5,243"); } }