Skip to main content

max / makenotwork

14.2 KB · 404 lines History Blame Raw
1 //! Public `/pricing` comparison model, the single source of truth for the
2 //! "you keep more with MNW" claim.
3 //!
4 //! The fee constants and every competitor's parameters live in
5 //! `docs/business/assumptions.toml` (`[stripe]`, `[pricing_calc]`,
6 //! `[[competitors]]`) and are parsed here at startup. This replaces the old
7 //! `static/page-pricing.js`, which hardcoded the Stripe rate and nine
8 //! competitor fee models client-side with no server backing and no test, a
9 //! public marketing claim that could silently drift from the real checkout
10 //! rate. The page now recomputes server-side via a debounced HTMX request; the
11 //! math never runs in the browser.
12 //!
13 //! Missing/malformed sections panic at startup (same contract as
14 //! `TierPrices::from_assumptions`): production never serves a half-loaded
15 //! comparison. The per-competitor formula is pinned by `tests` below to the
16 //! exact values the retired JS produced.
17
18 use std::path::Path;
19
20 use serde::Deserialize;
21
22 /// Stripe fee model, read from the `[stripe]` block. Extra keys in that block
23 /// (dispute fees, payout rates, connect sub-tables) are ignored.
24 #[derive(Debug, Clone, Deserialize)]
25 pub struct StripeFees {
26 pub percent: f64,
27 pub fixed: f64,
28 }
29
30 /// Calculator tunables from `[pricing_calc]`.
31 #[derive(Debug, Clone, Deserialize)]
32 pub struct PricingCalc {
33 /// Assumed average sale, used to estimate per-transaction fee count.
34 pub avg_transaction: f64,
35 }
36
37 /// How a competitor layers payment processing on top of its platform cut.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
39 #[serde(rename_all = "snake_case")]
40 pub enum StripeMode {
41 /// Processing is included in the platform cut (e.g. Lemon Squeezy).
42 None,
43 /// Adds the percent fee on the post-platform amount (no fixed per-tx).
44 Percent,
45 /// Adds both the percent fee and the fixed per-transaction fee.
46 PercentAndFixed,
47 }
48
49 /// One competitor's fee parameters. All numeric knobs default to zero/none so a
50 /// simple percentage-only platform needs just `platform_pct` + `stripe_mode`.
51 #[derive(Debug, Clone, Deserialize)]
52 pub struct Competitor {
53 pub name: String,
54 pub url: String,
55 pub fee_label: String,
56 #[serde(default)]
57 pub platform_pct: f64,
58 /// Reduced rate above `platform_pct_threshold` (Bandcamp: 10% over $5k).
59 #[serde(default)]
60 pub platform_pct_over: Option<f64>,
61 #[serde(default)]
62 pub platform_pct_threshold: Option<f64>,
63 /// Flat per-transaction platform fee, applied before processing (Gumroad,
64 /// Lemon Squeezy: $0.50).
65 #[serde(default)]
66 pub platform_per_tx: f64,
67 /// Flat monthly platform charge (Ko-fi Gold: $12).
68 #[serde(default)]
69 pub flat_monthly: f64,
70 pub stripe_mode: StripeMode,
71 }
72
73 /// Loaded pricing model. Built once at startup, held in `AppState`.
74 #[derive(Debug, Clone)]
75 pub struct PricingComparison {
76 stripe: StripeFees,
77 calc: PricingCalc,
78 competitors: Vec<Competitor>,
79 }
80
81 /// Shape of the subset of `assumptions.toml` this module cares about. Serde
82 /// ignores every other section, so the whole file deserializes cleanly.
83 #[derive(Deserialize)]
84 struct PricingToml {
85 stripe: StripeFees,
86 pricing_calc: PricingCalc,
87 #[serde(default)]
88 competitors: Vec<Competitor>,
89 }
90
91 impl PricingComparison {
92 /// Parse the pricing model from the assumptions TOML at `path`.
93 ///
94 /// Panics on a missing file or a malformed `[stripe]`/`[pricing_calc]`
95 /// section, the same startup contract as the other assumptions loaders.
96 pub fn load<P: AsRef<Path>>(path: P) -> Self {
97 let text = std::fs::read_to_string(path.as_ref()).unwrap_or_else(|e| {
98 panic!(
99 "failed to read assumptions for pricing comparison from {}: {e}",
100 path.as_ref().display()
101 )
102 });
103 Self::parse(&text)
104 }
105
106 /// Parse from a TOML string. Split out for testing.
107 pub fn parse(text: &str) -> Self {
108 let t: PricingToml = toml::from_str(text).unwrap_or_else(|e| {
109 panic!("failed to parse pricing sections of assumptions.toml: {e}")
110 });
111 Self {
112 stripe: t.stripe,
113 calc: t.pricing_calc,
114 competitors: t.competitors,
115 }
116 }
117
118 /// Estimated number of transactions to clear `revenue` at the assumed
119 /// average sale. Zero for non-positive revenue; otherwise at least one.
120 fn estimate_transactions(&self, revenue: f64) -> f64 {
121 if revenue <= 0.0 {
122 return 0.0;
123 }
124 (revenue / self.calc.avg_transaction).round().max(1.0)
125 }
126
127 /// What a creator keeps on MNW's processing alone (before membership).
128 fn stripe_net(&self, revenue: f64) -> f64 {
129 if revenue <= 0.0 {
130 return 0.0;
131 }
132 let txns = self.estimate_transactions(revenue);
133 revenue - revenue * self.stripe.percent - txns * self.stripe.fixed
134 }
135
136 /// Net revenue a creator keeps on a competitor at `revenue`.
137 fn competitor_net(&self, c: &Competitor, revenue: f64) -> f64 {
138 if revenue <= 0.0 {
139 return -c.flat_monthly;
140 }
141 let txns = self.estimate_transactions(revenue);
142 let pct = match (c.platform_pct_over, c.platform_pct_threshold) {
143 (Some(over), Some(threshold)) if revenue > threshold => over,
144 _ => c.platform_pct,
145 };
146 let after_platform = revenue * (1.0 - pct) - txns * c.platform_per_tx;
147 let after_processing = match c.stripe_mode {
148 StripeMode::None => after_platform,
149 StripeMode::Percent => after_platform - after_platform * self.stripe.percent,
150 StripeMode::PercentAndFixed => {
151 after_platform - after_platform * self.stripe.percent - txns * self.stripe.fixed
152 }
153 };
154 after_processing - c.flat_monthly
155 }
156
157 /// Build the full comparison for a given monthly `revenue` and selected
158 /// tier's monthly `tier_cost` (both in whole dollars). Rows are sorted by
159 /// net kept, descending, with the MNW row flagged.
160 pub fn compute(&self, revenue: f64, tier_cost: f64) -> ComparisonResult {
161 let mnw_net = self.stripe_net(revenue) - tier_cost;
162
163 struct Raw {
164 name: String,
165 url: String,
166 fee_label: String,
167 is_mnw: bool,
168 net: f64,
169 }
170
171 let mut raws = Vec::with_capacity(self.competitors.len() + 1);
172 raws.push(Raw {
173 name: "Makenot.work".to_string(),
174 url: String::new(),
175 fee_label: format!("{}/mo flat + ~3% processing", fmt_money(tier_cost)),
176 is_mnw: true,
177 net: mnw_net,
178 });
179 for c in &self.competitors {
180 raws.push(Raw {
181 name: c.name.clone(),
182 url: c.url.clone(),
183 fee_label: c.fee_label.clone(),
184 is_mnw: false,
185 net: self.competitor_net(c, revenue),
186 });
187 }
188 raws.sort_by(|a, b| {
189 b.net
190 .partial_cmp(&a.net)
191 .unwrap_or(std::cmp::Ordering::Equal)
192 });
193
194 let mut breakeven_any = false;
195 let rows = raws
196 .into_iter()
197 .map(|r| {
198 let keep = if revenue > 0.0 {
199 fmt_money(r.net.max(0.0))
200 } else {
201 "--".to_string()
202 };
203 let (diff, diff_class) = if r.is_mnw {
204 ("--".to_string(), String::new())
205 } else if revenue > 0.0 {
206 let d = mnw_net - r.net;
207 if d > 0.005 {
208 (format!("+{}", fmt_money(d)), "savings-negative".to_string())
209 } else if d < -0.005 {
210 breakeven_any = true;
211 (fmt_money(d), "savings-positive".to_string())
212 } else {
213 ("$0.00".to_string(), String::new())
214 }
215 } else {
216 ("--".to_string(), String::new())
217 };
218 ComparisonRow {
219 name: r.name,
220 url: r.url,
221 fee_label: r.fee_label,
222 is_mnw: r.is_mnw,
223 keep,
224 diff,
225 diff_class,
226 }
227 })
228 .collect();
229
230 let breakeven = if breakeven_any && revenue > 0.0 {
231 Some(format!(
232 "At {}/mo, some platforms with percentage-only fees cost less than \
233 MNW's flat rate. MNW becomes cheaper as your revenue grows.",
234 fmt_whole(revenue)
235 ))
236 } else {
237 None
238 };
239
240 ComparisonResult {
241 mnw_keep: fmt_money(mnw_net.max(0.0)),
242 mnw_detail: format!(
243 "of every {} after processing fees (~3%) and {}/mo membership",
244 fmt_whole(revenue),
245 fmt_money(tier_cost)
246 ),
247 rows,
248 breakeven,
249 }
250 }
251 }
252
253 /// Rendered comparison, handed to the pricing template / HTMX partial.
254 #[derive(Debug, Clone)]
255 pub struct ComparisonResult {
256 /// Formatted "you keep" headline for the MNW row (never negative).
257 pub mnw_keep: String,
258 /// Sub-line under the headline.
259 pub mnw_detail: String,
260 /// All rows (MNW + competitors), sorted by net kept, descending.
261 pub rows: Vec<ComparisonRow>,
262 /// Breakeven note when a competitor beats MNW at this revenue, else `None`.
263 pub breakeven: Option<String>,
264 }
265
266 /// One rendered comparison row. All display strings are preformatted so the
267 /// template does no math.
268 #[derive(Debug, Clone)]
269 pub struct ComparisonRow {
270 pub name: String,
271 /// Empty for the MNW row (rendered as bold text, not a link).
272 pub url: String,
273 pub fee_label: String,
274 pub is_mnw: bool,
275 /// "--" when revenue is zero, else formatted dollars.
276 pub keep: String,
277 /// "--" / "$0.00" / "+$x" / "$-x".
278 pub diff: String,
279 /// CSS class for the diff cell; empty when none applies.
280 pub diff_class: String,
281 }
282
283 /// Format dollars as `$1,234.56`, matching the retired JS `fmt`. Negative
284 /// values render the sign after the `$` (`$-12.34`) to match exactly.
285 fn fmt_money(n: f64) -> String {
286 let neg = n < 0.0;
287 let s = format!("{:.2}", n.abs());
288 let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
289 format!(
290 "${}{}.{}",
291 if neg { "-" } else { "" },
292 group_thousands(int_part),
293 frac
294 )
295 }
296
297 /// Format dollars as a whole number with thousands separators (`$1,000`),
298 /// matching the retired JS `fmtWhole`.
299 fn fmt_whole(n: f64) -> String {
300 format!("${}", group_thousands(&format!("{:.0}", n.round().abs())))
301 }
302
303 /// Insert commas every three digits from the right into a bare integer string.
304 fn group_thousands(digits: &str) -> String {
305 let bytes = digits.as_bytes();
306 let len = bytes.len();
307 let mut out = String::with_capacity(len + len / 3);
308 for (i, b) in bytes.iter().enumerate() {
309 if i > 0 && (len - i).is_multiple_of(3) {
310 out.push(',');
311 }
312 out.push(*b as char);
313 }
314 out
315 }
316
317 #[cfg(test)]
318 mod tests {
319 use super::*;
320
321 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
322
323 fn loaded() -> PricingComparison {
324 PricingComparison::load(ASSUMPTIONS_PATH)
325 }
326
327 /// The canonical toml loads and carries all nine competitors.
328 #[test]
329 fn loads_from_canonical_assumptions() {
330 let p = loaded();
331 assert_eq!(p.competitors.len(), 9, "expected 9 competitors");
332 assert!((p.stripe.percent - 0.029).abs() < 1e-9);
333 assert!((p.stripe.fixed - 0.30).abs() < 1e-9);
334 assert!((p.calc.avg_transaction - 25.0).abs() < 1e-9);
335 }
336
337 fn net_of(result: &ComparisonResult, name: &str) -> String {
338 result
339 .rows
340 .iter()
341 .find(|r| r.name == name)
342 .unwrap_or_else(|| panic!("row {name} missing"))
343 .keep
344 .clone()
345 }
346
347 /// Pin the headline + every competitor's kept-amount at $1,000/mo, Basic
348 /// tier ($16). These are the exact figures the retired page-pricing.js
349 /// produced, the regression anchor the JS never had.
350 #[test]
351 fn pins_net_at_1000_basic() {
352 let p = loaded();
353 let r = p.compute(1000.0, 16.0);
354 // stripe_net(1000) = 1000 - 29 - 40*0.30 = 959; minus $16 = 943.
355 assert_eq!(r.mnw_keep, "$943.00");
356 assert_eq!(net_of(&r, "Makenot.work"), "$943.00");
357 assert_eq!(net_of(&r, "Patreon"), "$861.90"); // 900 - 26.1 - 12
358 assert_eq!(net_of(&r, "Gumroad"), "$854.48"); // (900-20) - 25.52
359 assert_eq!(net_of(&r, "Bandcamp"), "$825.35"); // 850 - 24.65
360 assert_eq!(net_of(&r, "Ko-fi Gold"), "$947.00"); // 959 - 12
361 assert_eq!(net_of(&r, "Lemon Squeezy"), "$930.00"); // 950 - 20
362 }
363
364 /// Rows are sorted by net kept, descending, and the MNW row is present.
365 #[test]
366 fn rows_sorted_descending() {
367 let r = loaded().compute(1000.0, 16.0);
368 assert_eq!(r.rows.len(), 10);
369 assert!(r.rows.iter().any(|row| row.is_mnw));
370 // At $1,000 Ko-fi Gold ($947) beats MNW Basic ($943), so MNW is not
371 // first and a breakeven note appears.
372 assert!(r.breakeven.is_some());
373 assert_eq!(r.rows[0].name, "Ko-fi Gold");
374 }
375
376 /// Bandcamp's rate drops from 15% to 10% above the $5k threshold.
377 #[test]
378 fn bandcamp_tier_switch_at_threshold() {
379 let p = loaded();
380 // At $4,000 (below): 15%. ap = 3400; net = 3400 - 3400*0.029 = 3301.40
381 assert_eq!(net_of(&p.compute(4000.0, 16.0), "Bandcamp"), "$3,301.40");
382 // At $6,000 (above): 10%. ap = 5400; net = 5400 - 5400*0.029 = 5243.40
383 assert_eq!(net_of(&p.compute(6000.0, 16.0), "Bandcamp"), "$5,243.40");
384 }
385
386 /// Zero revenue renders placeholders, not computed money.
387 #[test]
388 fn zero_revenue_is_placeholder() {
389 let r = loaded().compute(0.0, 16.0);
390 assert_eq!(r.mnw_keep, "$0.00");
391 assert!(r.rows.iter().all(|row| row.keep == "--"));
392 assert!(r.breakeven.is_none());
393 }
394
395 #[test]
396 fn money_formatting_matches_js() {
397 assert_eq!(fmt_money(943.0), "$943.00");
398 assert_eq!(fmt_money(1234.5), "$1,234.50");
399 assert_eq!(fmt_money(-12.34), "$-12.34");
400 assert_eq!(fmt_whole(1000.0), "$1,000");
401 assert_eq!(fmt_whole(5243.4), "$5,243");
402 }
403 }
404