//! Public `/pricing` fee calculator: what a creator keeps on MNW against what //! they keep on a platform whose fees they describe themselves. //! //! This replaces the old `pricing_comparison` module, which shipped a table of //! nine named platforms with their fee parameters hardcoded in //! `assumptions.toml`. Three problems with that table, all of which this design //! removes rather than mitigates: //! //! - It named competitors, which house copy rules forbid. //! - It went stale silently. Every rate in it was a claim about somebody else's //! pricing page, and nobody was tracking those pages. //! - It only ever argued one direction. A comparison that always wins reads as //! a sales prop. //! //! Here the other platform's fees are two dials the visitor sets, so there is //! nothing to keep fresh, nothing to name, and no way to rig the outcome. The //! calculator reports the crossover honestly, including the case where MNW's //! flat fee never pays for itself at the entered item price. //! //! The model, all monthly: //! //! ```text //! gross = item_price * sales //! mnw_net = gross - gross*stripe.percent - sales*stripe.fixed - tier_cost //! other_net = gross - gross*other_pct - sales*other_per_sale //! ``` //! //! MNW charges no cut, so its variable side is payment processing alone; the //! tier subscription is the whole fixed side. The other platform's dials are //! its *total* deduction, its own cut and whatever processing it layers on, //! because that is the number a creator can read off a payout. //! //! The difference is linear in sales, which is what makes the crossover exact //! rather than searched for: //! //! ```text //! per_sale_gain = item_price*(other_pct - stripe.percent) //! + (other_per_sale - stripe.fixed) //! mnw_net - other_net = sales*per_sale_gain - tier_cost //! ``` //! //! So MNW pulls ahead at `tier_cost / per_sale_gain` sales when //! `per_sale_gain` is positive, and never when it is not. Both branches are //! rendered. //! //! Stripe's fees and the input defaults come from //! `docs/business/assumptions.toml` (`[stripe]`, `[fee_calculator]`) and are //! parsed at startup. A missing or malformed section panics, the same contract //! as `TierPrices::from_assumptions`: production never serves a half-loaded //! calculator. The arithmetic is pinned by `tests` below and never runs in the //! browser. 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, } /// Where the dials sit before the visitor touches anything, read from /// `[fee_calculator]`. Picked so the first render is a realistic scenario /// rather than a strawman. #[derive(Debug, Clone, Deserialize)] pub struct CalculatorDefaults { pub item_price: f64, pub sales_per_month: f64, /// Other platform's total percentage deduction, as a fraction. pub other_pct: f64, /// Other platform's total flat deduction per sale, in dollars. pub other_per_sale: f64, } /// Loaded calculator. Built once at startup, held in `AppState`. #[derive(Debug, Clone)] pub struct FeeCalculator { stripe: StripeFees, defaults: CalculatorDefaults, } /// 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 CalculatorToml { stripe: StripeFees, fee_calculator: CalculatorDefaults, } /// One set of dial positions. Every field is already clamped to its input /// range by [`FeeCalculator::sanitize`]; `compute` assumes that. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Inputs { pub item_price: f64, pub sales_per_month: f64, /// Monthly cost of the selected MNW tier. pub tier_cost: f64, pub other_pct: f64, pub other_per_sale: f64, } /// Widest each dial may go. The upper bounds are display sanity, not business /// limits: past them the layout breaks before the arithmetic does. pub const MAX_ITEM_PRICE: f64 = 10_000.0; pub const MAX_SALES: f64 = 100_000.0; pub const MAX_OTHER_PCT: f64 = 0.9; pub const MAX_OTHER_PER_SALE: f64 = 100.0; impl FeeCalculator { /// Parse the calculator from the assumptions TOML at `path`. /// /// Panics on a missing file or a malformed `[stripe]`/`[fee_calculator]` /// 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 the fee calculator 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: CalculatorToml = toml::from_str(text).unwrap_or_else(|e| { panic!("failed to parse calculator sections of assumptions.toml: {e}") }); Self { stripe: t.stripe, defaults: t.fee_calculator, } } /// The dial positions the page opens on, for the given tier cost. pub fn default_inputs(&self, tier_cost: f64) -> Inputs { Inputs { item_price: self.defaults.item_price, sales_per_month: self.defaults.sales_per_month, tier_cost, other_pct: self.defaults.other_pct, other_per_sale: self.defaults.other_per_sale, } } /// Clamp raw inputs into range. Non-finite values fall back to the /// default for that dial rather than to zero, so a garbage query string /// still renders the realistic opening scenario. pub fn sanitize(&self, raw: Inputs) -> Inputs { let d = &self.defaults; let clamp = |v: f64, fallback: f64, lo: f64, hi: f64| { if v.is_finite() { v.clamp(lo, hi) } else { fallback } }; Inputs { item_price: clamp(raw.item_price, d.item_price, 0.0, MAX_ITEM_PRICE), sales_per_month: clamp(raw.sales_per_month, d.sales_per_month, 0.0, MAX_SALES).round(), tier_cost: clamp(raw.tier_cost, 0.0, 0.0, MAX_ITEM_PRICE), other_pct: clamp(raw.other_pct, d.other_pct, 0.0, MAX_OTHER_PCT), other_per_sale: clamp( raw.other_per_sale, d.other_per_sale, 0.0, MAX_OTHER_PER_SALE, ), } } /// What MNW keeps: gross less processing, less the tier subscription. fn mnw_net(&self, i: Inputs) -> f64 { let gross = i.item_price * i.sales_per_month; gross - gross * self.stripe.percent - i.sales_per_month * self.stripe.fixed - i.tier_cost } /// What the described platform keeps, on the dials as entered. Takes /// nothing from `self`: we hold no rates for anyone but ourselves. fn other_net(i: Inputs) -> f64 { let gross = i.item_price * i.sales_per_month; gross - gross * i.other_pct - i.sales_per_month * i.other_per_sale } /// How much more of each individual sale survives on MNW. Negative means /// MNW's processing costs more per sale than the other platform's total /// deduction, so the flat fee can never be made back. fn per_sale_gain(&self, i: Inputs) -> f64 { i.item_price * (i.other_pct - self.stripe.percent) + (i.other_per_sale - self.stripe.fixed) } /// Run the calculator. Every string on the returned struct is display /// ready; the template does no arithmetic and no formatting. pub fn compute(&self, i: Inputs) -> Outcome { let gross = i.item_price * i.sales_per_month; let mnw = self.mnw_net(i); let other = Self::other_net(i); let gain = self.per_sale_gain(i); // Exact crossover in sales, as a real number. `None` when MNW's // per-sale advantage is zero or negative: no volume closes that gap, // and saying so is the honest answer. let crossover = (gain > 0.0 && i.tier_cost > 0.0).then(|| i.tier_cost / gain); let verdict = match crossover { _ if (mnw - other).abs() < 0.005 => Verdict::Even, _ if mnw > other => Verdict::MnwAhead, Some(_) => Verdict::OtherAheadForNow, None => Verdict::OtherAhead, }; let headline = match verdict { Verdict::MnwAhead => format!("You keep {} more here", fmt_money(mnw - other)), Verdict::Even => "The two come out the same here".to_string(), Verdict::OtherAheadForNow | Verdict::OtherAhead => { format!( "The other platform keeps {} more here", fmt_money(other - mnw) ) } }; let crossover_note = match (verdict, crossover) { (Verdict::OtherAhead, _) => Some(format!( "At {} an item, our processing costs more per sale than the fees you \ entered, so no amount of volume closes the gap. At these numbers the \ other platform is the cheaper place to sell.", fmt_money(i.item_price) )), (_, Some(c)) => { let whole = (c.floor() + 1.0).min(MAX_SALES); Some(format!( "The crossover is at {} sales a month. Below that the other platform \ costs less, because our fee is flat and theirs is not. From {} sales \ on, we cost less, and the gap widens from there.", fmt_count(c.ceil()), fmt_count(whole) )) } (_, None) => None, }; Outcome { gross: fmt_money(gross), mnw_keep: fmt_money(mnw), other_keep: fmt_money(other), mnw_rate: fmt_rate(gross, mnw), other_rate: fmt_rate(gross, other), headline, verdict, crossover_note, scale: Scale::build(i.sales_per_month, crossover, verdict), } } } /// Which side of the crossover the entered numbers land on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Verdict { /// MNW keeps more at these numbers. MnwAhead, /// The two are within half a cent. Even, /// The other platform keeps more, but volume would close it. OtherAheadForNow, /// The other platform keeps more at every volume. OtherAhead, } impl Verdict { /// CSS modifier for the headline, so the template branches on nothing. pub fn css_class(self) -> &'static str { match self { Verdict::MnwAhead => "verdict--ahead", Verdict::Even => "verdict--even", Verdict::OtherAheadForNow | Verdict::OtherAhead => "verdict--behind", } } } /// The sales axis under the numbers, marking where each platform wins. /// /// Rendered as one bar: a leading segment where the other platform costs less, /// the rest where MNW does, and a marker at the entered volume. All three are /// percentages of the bar so the template needs no units and no math. #[derive(Debug, Clone)] pub struct Scale { /// Width of the segment where the other platform wins, as a percentage. pub other_pct_width: String, /// Width of the segment where MNW wins, as a percentage. pub mnw_pct_width: String, /// Position of the "you are here" marker, as a percentage. pub marker_pct: String, /// Right-hand end of the axis, e.g. `160 sales`. pub max_label: String, /// Whether there is a crossover tick to draw at all. Kept alongside /// `crossover_label` so the template can branch without unwrapping. pub has_crossover: bool, /// Label under the crossover tick, `None` when there is no crossover. pub crossover_label: Option, /// Position of the crossover tick, as a percentage. Empty with no tick. pub crossover_pct: String, } impl Scale { fn build(sales: f64, crossover: Option, verdict: Verdict) -> Self { // Show at least twice the interesting point so both regions are // visible, and never a degenerate zero-width axis. let interest = crossover.unwrap_or(0.0).max(sales); let max = (interest * 2.0).max(10.0).ceil().min(MAX_SALES * 2.0); let pct = |v: f64| format!("{:.4}", (v / max * 100.0).clamp(0.0, 100.0)); match crossover { Some(c) => { let split = (c / max * 100.0).clamp(0.0, 100.0); Self { other_pct_width: format!("{split:.4}"), mnw_pct_width: format!("{:.4}", 100.0 - split), marker_pct: pct(sales), max_label: format!("{} sales", fmt_count(max)), has_crossover: true, crossover_label: Some(format!("{} sales", fmt_count(c.ceil()))), crossover_pct: format!("{split:.4}"), } } // No crossover: one region spans the whole axis. Which one depends // on whether MNW is ahead everywhere or behind everywhere. None => { let mnw_everywhere = verdict == Verdict::MnwAhead || verdict == Verdict::Even; Self { other_pct_width: if mnw_everywhere { "0" } else { "100" }.to_string(), mnw_pct_width: if mnw_everywhere { "100" } else { "0" }.to_string(), marker_pct: pct(sales), max_label: format!("{} sales", fmt_count(max)), has_crossover: false, crossover_label: None, crossover_pct: String::new(), } } } } } /// Rendered calculator output, handed to the page and the HTMX partial. #[derive(Debug, Clone)] pub struct Outcome { /// Monthly gross before anyone's fees. pub gross: String, /// Monthly take-home on MNW. Negative when the tier costs more than the /// sales bring in, which is rendered rather than clamped away. pub mnw_keep: String, /// Monthly take-home on the described platform. pub other_keep: String, /// Effective total fee as a percentage of gross, e.g. `4.2%`. pub mnw_rate: String, pub other_rate: String, /// One-line statement of who comes out ahead at these numbers. pub headline: String, pub verdict: Verdict, /// Where the crossover sits, or why there isn't one. pub crossover_note: Option, pub scale: Scale, } /// Format dollars as `$1,234.56`, with the sign ahead of the `$`. fn fmt_money(n: f64) -> String { let rounded = (n * 100.0).round() / 100.0; let s = format!("{:.2}", rounded.abs()); let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00")); format!( "{}${}.{}", if rounded < 0.0 { "-" } else { "" }, group_thousands(int_part), frac ) } /// Format a whole count with thousands separators (`1,000`). fn fmt_count(n: f64) -> String { group_thousands(&format!("{:.0}", n.round().abs())) } /// Total fees as a percentage of gross, to one decimal. `--` when there is no /// gross to take a percentage of. fn fmt_rate(gross: f64, net: f64) -> String { if gross <= 0.0 { return "--".to_string(); } format!("{:.1}%", (gross - net) / gross * 100.0) } /// 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() -> FeeCalculator { FeeCalculator::load(ASSUMPTIONS_PATH) } /// Float equality for values that are exactly a clamp bound or a copied /// default, spelled with a tolerance so `float_cmp` stays happy. #[track_caller] fn approx(got: f64, want: f64, what: &str) { assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}"); } /// The canonical toml loads with the fee constants the checkout uses. #[test] fn loads_from_canonical_assumptions() { let c = loaded(); assert!((c.stripe.percent - 0.029).abs() < 1e-9); assert!((c.stripe.fixed - 0.30).abs() < 1e-9); assert!(c.defaults.item_price > 0.0); assert!(c.defaults.sales_per_month > 0.0); assert!(c.defaults.other_pct > 0.0); } /// Both nets at a round scenario: 40 sales of $25 is $1,000 gross, the /// other platform on 12.6% + $0.30. /// /// mnw = 1000 - 29 - 12 - 16 = 943 /// other = 1000 - 126 - 12 = 862 #[test] fn pins_both_nets() { let c = loaded(); let out = c.compute(Inputs { item_price: 25.0, sales_per_month: 40.0, tier_cost: 16.0, other_pct: 0.126, other_per_sale: 0.30, }); assert_eq!(out.gross, "$1,000.00"); assert_eq!(out.mnw_keep, "$943.00"); assert_eq!(out.other_keep, "$862.00"); assert_eq!(out.verdict, Verdict::MnwAhead); assert_eq!(out.mnw_rate, "5.7%"); assert_eq!(out.other_rate, "13.8%"); } /// The crossover is exact, not searched for: per-sale gain is /// 25*(0.126-0.029) + (0.30-0.30) = 2.425, so $16 of tier is made back at /// 16/2.425 = 6.598 sales, and the copy rounds up to 7. #[test] fn crossover_is_where_the_flat_fee_is_repaid() { let c = loaded(); let i = Inputs { item_price: 25.0, sales_per_month: 3.0, tier_cost: 16.0, other_pct: 0.126, other_per_sale: 0.30, }; let out = c.compute(i); assert_eq!(out.verdict, Verdict::OtherAheadForNow); let note = out.crossover_note.expect("crossover note"); assert!(note.contains("7 sales a month"), "note was: {note}"); // One sale either side of the rounded crossover confirms the flip. let below = c.compute(Inputs { sales_per_month: 6.0, ..i }); assert_eq!(below.verdict, Verdict::OtherAheadForNow); let above = c.compute(Inputs { sales_per_month: 7.0, ..i }); assert_eq!(above.verdict, Verdict::MnwAhead); } /// HONESTY CONTRACT. A platform charging less per sale than bare payment /// processing wins at every volume, and the calculator has to say so /// instead of promising a crossover that does not exist. #[test] fn no_crossover_when_the_other_platform_is_cheaper_per_sale() { let c = loaded(); let out = c.compute(Inputs { item_price: 5.0, sales_per_month: 500.0, tier_cost: 16.0, // 1% and no flat fee: below Stripe's own rate. other_pct: 0.01, other_per_sale: 0.0, }); assert_eq!(out.verdict, Verdict::OtherAhead); let note = out.crossover_note.expect("no-crossover note"); assert!(note.contains("no amount of volume"), "note was: {note}"); // The whole axis belongs to the other platform. assert_eq!(out.scale.mnw_pct_width, "0"); assert_eq!(out.scale.other_pct_width, "100"); assert!(out.scale.crossover_label.is_none()); } /// A free tier has nothing to repay, so MNW leads from the first sale and /// there is no crossover to draw. #[test] fn zero_tier_cost_has_no_crossover() { let out = loaded().compute(Inputs { item_price: 25.0, sales_per_month: 40.0, tier_cost: 0.0, other_pct: 0.126, other_per_sale: 0.30, }); assert_eq!(out.verdict, Verdict::MnwAhead); assert!(out.crossover_note.is_none()); assert_eq!(out.scale.mnw_pct_width, "100"); } /// Zero sales is a real answer, not a placeholder: the subscription still /// costs what it costs, and the page shows the negative. #[test] fn zero_sales_shows_the_subscription_as_a_loss() { let out = loaded().compute(Inputs { item_price: 25.0, sales_per_month: 0.0, tier_cost: 16.0, other_pct: 0.126, other_per_sale: 0.30, }); assert_eq!(out.gross, "$0.00"); assert_eq!(out.mnw_keep, "-$16.00"); assert_eq!(out.other_keep, "$0.00"); assert_eq!(out.verdict, Verdict::OtherAheadForNow); assert_eq!(out.mnw_rate, "--"); } /// Identical fee structures land on Even rather than on either side. #[test] fn identical_terms_are_even() { let out = loaded().compute(Inputs { item_price: 25.0, sales_per_month: 40.0, tier_cost: 0.0, other_pct: 0.029, other_per_sale: 0.30, }); assert_eq!(out.verdict, Verdict::Even); assert_eq!(out.headline, "The two come out the same here"); } /// Out-of-range and garbage dials clamp instead of 400ing or rendering /// nonsense. Non-finite falls back to the default, not to zero. #[test] fn sanitize_clamps_every_dial() { let c = loaded(); let s = c.sanitize(Inputs { item_price: -5.0, sales_per_month: 1e12, tier_cost: -1.0, other_pct: 4.0, other_per_sale: f64::NAN, }); approx(s.item_price, 0.0, "item_price clamps up to the floor"); approx( s.sales_per_month, MAX_SALES, "sales clamp down to the ceiling", ); approx(s.tier_cost, 0.0, "tier cost cannot go negative"); approx( s.other_pct, MAX_OTHER_PCT, "their cut clamps to the ceiling", ); approx( s.other_per_sale, c.defaults.other_per_sale, "NaN falls back to the default, not to zero", ); let inf = c.sanitize(Inputs { item_price: f64::INFINITY, sales_per_month: f64::NEG_INFINITY, tier_cost: 16.0, other_pct: f64::NAN, other_per_sale: 0.30, }); approx(inf.item_price, c.defaults.item_price, "+inf price"); approx( inf.sales_per_month, c.defaults.sales_per_month, "-inf sales", ); approx(inf.other_pct, c.defaults.other_pct, "NaN cut"); } /// The bar's two segments always tile the axis exactly, and the marker /// stays inside it, at every dial position the inputs allow. #[test] fn scale_segments_tile_the_axis() { let c = loaded(); for sales in [0.0, 1.0, 7.0, 40.0, 5_000.0, MAX_SALES] { for price in [0.0, 1.0, 25.0, MAX_ITEM_PRICE] { for pct in [0.0, 0.029, 0.126, MAX_OTHER_PCT] { let out = c.compute(Inputs { item_price: price, sales_per_month: sales, tier_cost: 16.0, other_pct: pct, other_per_sale: 0.30, }); let a: f64 = out.scale.other_pct_width.parse().unwrap(); let b: f64 = out.scale.mnw_pct_width.parse().unwrap(); assert!( (a + b - 100.0).abs() < 0.01, "segments {a} + {b} at price {price}, {sales} sales, pct {pct}" ); let m: f64 = out.scale.marker_pct.parse().unwrap(); assert!((0.0..=100.0).contains(&m), "marker {m} out of the axis"); } } } } #[test] fn money_and_count_formatting() { 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_money(-0.001), "$0.00"); assert_eq!(fmt_count(1000.0), "1,000"); assert_eq!(fmt_rate(1000.0, 943.0), "5.7%"); assert_eq!(fmt_rate(0.0, -16.0), "--"); } }