Skip to main content

max / makenotwork

21.4 KB · 582 lines History Blame Raw
1 //! Public `/pricing` fee calculator: what a creator keeps on MNW against what
2 //! they keep on a platform whose fees they describe themselves.
3 //!
4 //! The other platform's fees are two dials the visitor sets, never a table of
5 //! named competitors with hardcoded rates. A table names competitors, which
6 //! house copy rules forbid; it goes stale silently, since every rate in it is a
7 //! claim about somebody else's pricing page; and it only ever argues one
8 //! direction. Dials leave nothing to keep fresh, nothing to name, and no way to
9 //! rig the outcome. The
10 //! calculator reports the crossover honestly, including the case where MNW's
11 //! flat fee never pays for itself at the entered item price.
12 //!
13 //! The model, all monthly:
14 //!
15 //! ```text
16 //! gross = item_price * sales
17 //! mnw_net = gross - gross*stripe.percent - sales*stripe.fixed - tier_cost
18 //! other_net = gross - gross*other_pct - sales*other_per_sale
19 //! ```
20 //!
21 //! MNW charges no cut, so its variable side is payment processing alone; the
22 //! tier subscription is the whole fixed side. The other platform's dials are
23 //! its *total* deduction, its own cut and whatever processing it layers on,
24 //! because that is the number a creator can read off a payout.
25 //!
26 //! The difference is linear in sales, which is what makes the crossover exact
27 //! rather than searched for:
28 //!
29 //! ```text
30 //! per_sale_gain = item_price*(other_pct - stripe.percent)
31 //! + (other_per_sale - stripe.fixed)
32 //! mnw_net - other_net = sales*per_sale_gain - tier_cost
33 //! ```
34 //!
35 //! So MNW pulls ahead at `tier_cost / per_sale_gain` sales when
36 //! `per_sale_gain` is positive, and never when it is not. Both branches are
37 //! rendered.
38 //!
39 //! Stripe's fees and the input defaults come from
40 //! `docs/business/assumptions.toml` (`[stripe]`, `[fee_calculator]`) and are
41 //! parsed at startup. A missing or malformed section panics, the same contract
42 //! as `TierPrices::from_assumptions`: production never serves a half-loaded
43 //! calculator. The arithmetic is pinned by `tests` below and never runs in the
44 //! browser.
45
46 use std::path::Path;
47
48 use serde::Deserialize;
49
50 /// Stripe fee model, read from the `[stripe]` block. Extra keys in that block
51 /// (dispute fees, payout rates, connect sub-tables) are ignored.
52 #[derive(Debug, Clone, Deserialize)]
53 pub struct StripeFees {
54 pub percent: f64,
55 pub fixed: f64,
56 }
57
58 /// Where the dials sit before the visitor touches anything, read from
59 /// `[fee_calculator]`. Picked so the first render is a realistic scenario
60 /// rather than a strawman.
61 #[derive(Debug, Clone, Deserialize)]
62 pub struct CalculatorDefaults {
63 pub item_price: f64,
64 pub sales_per_month: f64,
65 /// Other platform's total percentage deduction, as a fraction.
66 pub other_pct: f64,
67 /// Other platform's total flat deduction per sale, in dollars.
68 pub other_per_sale: f64,
69 }
70
71 /// Loaded calculator. Built once at startup, held in `AppState`.
72 #[derive(Debug, Clone)]
73 pub struct FeeCalculator {
74 stripe: StripeFees,
75 defaults: CalculatorDefaults,
76 }
77
78 /// Shape of the subset of `assumptions.toml` this module cares about. Serde
79 /// ignores every other section, so the whole file deserializes cleanly.
80 #[derive(Deserialize)]
81 struct CalculatorToml {
82 stripe: StripeFees,
83 fee_calculator: CalculatorDefaults,
84 }
85
86 /// One set of dial positions. Every field is already clamped to its input
87 /// range by [`FeeCalculator::sanitize`]; `compute` assumes that.
88 #[derive(Debug, Clone, Copy, PartialEq)]
89 pub struct Inputs {
90 pub item_price: f64,
91 pub sales_per_month: f64,
92 /// Monthly cost of the selected MNW tier.
93 pub tier_cost: f64,
94 pub other_pct: f64,
95 pub other_per_sale: f64,
96 }
97
98 /// Widest each dial may go. The upper bounds are display sanity, not business
99 /// limits: past them the layout breaks before the arithmetic does.
100 pub const MAX_ITEM_PRICE: f64 = 10_000.0;
101 pub const MAX_SALES: f64 = 100_000.0;
102 pub const MAX_OTHER_PCT: f64 = 0.9;
103 pub const MAX_OTHER_PER_SALE: f64 = 100.0;
104
105 impl FeeCalculator {
106 /// Parse the calculator from the assumptions TOML at `path`.
107 ///
108 /// Panics on a missing file or a malformed `[stripe]`/`[fee_calculator]`
109 /// section, the same startup contract as the other assumptions loaders.
110 pub fn load<P: AsRef<Path>>(path: P) -> Self {
111 let text = std::fs::read_to_string(path.as_ref()).unwrap_or_else(|e| {
112 panic!(
113 "failed to read assumptions for the fee calculator from {}: {e}",
114 path.as_ref().display()
115 )
116 });
117 Self::parse(&text)
118 }
119
120 /// Parse from a TOML string. Split out for testing.
121 pub fn parse(text: &str) -> Self {
122 let t: CalculatorToml = toml::from_str(text).unwrap_or_else(|e| {
123 panic!("failed to parse calculator sections of assumptions.toml: {e}")
124 });
125 Self {
126 stripe: t.stripe,
127 defaults: t.fee_calculator,
128 }
129 }
130
131 /// The dial positions the page opens on, for the given tier cost.
132 pub fn default_inputs(&self, tier_cost: f64) -> Inputs {
133 Inputs {
134 item_price: self.defaults.item_price,
135 sales_per_month: self.defaults.sales_per_month,
136 tier_cost,
137 other_pct: self.defaults.other_pct,
138 other_per_sale: self.defaults.other_per_sale,
139 }
140 }
141
142 /// Clamp raw inputs into range. Non-finite values fall back to the
143 /// default for that dial rather than to zero, so a garbage query string
144 /// still renders the realistic opening scenario.
145 pub fn sanitize(&self, raw: Inputs) -> Inputs {
146 let d = &self.defaults;
147 let clamp = |v: f64, fallback: f64, lo: f64, hi: f64| {
148 if v.is_finite() {
149 v.clamp(lo, hi)
150 } else {
151 fallback
152 }
153 };
154 Inputs {
155 item_price: clamp(raw.item_price, d.item_price, 0.0, MAX_ITEM_PRICE),
156 sales_per_month: clamp(raw.sales_per_month, d.sales_per_month, 0.0, MAX_SALES).round(),
157 tier_cost: clamp(raw.tier_cost, 0.0, 0.0, MAX_ITEM_PRICE),
158 other_pct: clamp(raw.other_pct, d.other_pct, 0.0, MAX_OTHER_PCT),
159 other_per_sale: clamp(
160 raw.other_per_sale,
161 d.other_per_sale,
162 0.0,
163 MAX_OTHER_PER_SALE,
164 ),
165 }
166 }
167
168 /// What MNW keeps: gross less processing, less the tier subscription.
169 fn mnw_net(&self, i: Inputs) -> f64 {
170 let gross = i.item_price * i.sales_per_month;
171 gross - gross * self.stripe.percent - i.sales_per_month * self.stripe.fixed - i.tier_cost
172 }
173
174 /// What the described platform keeps, on the dials as entered. Takes
175 /// nothing from `self`: we hold no rates for anyone but ourselves.
176 fn other_net(i: Inputs) -> f64 {
177 let gross = i.item_price * i.sales_per_month;
178 gross - gross * i.other_pct - i.sales_per_month * i.other_per_sale
179 }
180
181 /// How much more of each individual sale survives on MNW. Negative means
182 /// MNW's processing costs more per sale than the other platform's total
183 /// deduction, so the flat fee can never be made back.
184 fn per_sale_gain(&self, i: Inputs) -> f64 {
185 i.item_price * (i.other_pct - self.stripe.percent) + (i.other_per_sale - self.stripe.fixed)
186 }
187
188 /// Run the calculator. Every string on the returned struct is display
189 /// ready; the template does no arithmetic and no formatting.
190 pub fn compute(&self, i: Inputs) -> Outcome {
191 let gross = i.item_price * i.sales_per_month;
192 let mnw = self.mnw_net(i);
193 let other = Self::other_net(i);
194 let gain = self.per_sale_gain(i);
195
196 // Exact crossover in sales, as a real number. `None` when MNW's
197 // per-sale advantage is zero or negative: no volume closes that gap,
198 // and saying so is the honest answer.
199 let crossover = (gain > 0.0 && i.tier_cost > 0.0).then(|| i.tier_cost / gain);
200
201 let verdict = match crossover {
202 _ if (mnw - other).abs() < 0.005 => Verdict::Even,
203 _ if mnw > other => Verdict::MnwAhead,
204 Some(_) => Verdict::OtherAheadForNow,
205 None => Verdict::OtherAhead,
206 };
207
208 let headline = match verdict {
209 Verdict::MnwAhead => format!("You keep {} more here", fmt_money(mnw - other)),
210 Verdict::Even => "The two come out the same here".to_string(),
211 Verdict::OtherAheadForNow | Verdict::OtherAhead => {
212 format!(
213 "The other platform keeps {} more here",
214 fmt_money(other - mnw)
215 )
216 }
217 };
218
219 let crossover_note = match (verdict, crossover) {
220 (Verdict::OtherAhead, _) => Some(format!(
221 "At {} an item, our processing costs more per sale than the fees you \
222 entered, so no amount of volume closes the gap. At these numbers the \
223 other platform is the cheaper place to sell.",
224 fmt_money(i.item_price)
225 )),
226 (_, Some(c)) => {
227 let whole = (c.floor() + 1.0).min(MAX_SALES);
228 Some(format!(
229 "The crossover is at {} sales a month. Below that the other platform \
230 costs less, because our fee is flat and theirs is not. From {} sales \
231 on, we cost less, and the gap widens from there.",
232 fmt_count(c.ceil()),
233 fmt_count(whole)
234 ))
235 }
236 (_, None) => None,
237 };
238
239 Outcome {
240 gross: fmt_money(gross),
241 mnw_keep: fmt_money(mnw),
242 other_keep: fmt_money(other),
243 mnw_rate: fmt_rate(gross, mnw),
244 other_rate: fmt_rate(gross, other),
245 headline,
246 verdict,
247 crossover_note,
248 sales_per_month: i.sales_per_month,
249 crossover_sales: crossover,
250 }
251 }
252 }
253
254 /// Which side of the crossover the entered numbers land on.
255 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
256 pub enum Verdict {
257 /// MNW keeps more at these numbers.
258 MnwAhead,
259 /// The two are within half a cent.
260 Even,
261 /// The other platform keeps more, but volume would close it.
262 OtherAheadForNow,
263 /// The other platform keeps more at every volume.
264 OtherAhead,
265 }
266
267 /// Rendered calculator output, handed to the page and the HTMX partial.
268 #[derive(Debug, Clone)]
269 pub struct Outcome {
270 /// Monthly gross before anyone's fees.
271 pub gross: String,
272 /// Monthly take-home on MNW. Negative when the tier costs more than the
273 /// sales bring in, which is rendered rather than clamped away.
274 pub mnw_keep: String,
275 /// Monthly take-home on the described platform.
276 pub other_keep: String,
277 /// Effective total fee as a percentage of gross, e.g. `4.2%`.
278 pub mnw_rate: String,
279 pub other_rate: String,
280 /// One-line statement of who comes out ahead at these numbers.
281 pub headline: String,
282 pub verdict: Verdict,
283 /// Where the crossover sits, or why there isn't one.
284 pub crossover_note: Option<String>,
285 /// The volume the dials were set to, so the panel can say where that sits
286 /// against the crossover. Carried rather than recomputed: the reader typed
287 /// it, `sanitize` rounded it, and a second rounding downstream is a second
288 /// answer.
289 pub sales_per_month: f64,
290 /// Sales a month at which we become the cheaper place, exactly. `None` when
291 /// our per-sale advantage is zero or negative, which is the case no volume
292 /// closes.
293 pub crossover_sales: Option<f64>,
294 }
295
296 /// Format dollars as `$1,234.56`, with the sign ahead of the `$`.
297 fn fmt_money(n: f64) -> String {
298 let rounded = (n * 100.0).round() / 100.0;
299 let s = format!("{:.2}", rounded.abs());
300 let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
301 format!(
302 "{}${}.{}",
303 if rounded < 0.0 { "-" } else { "" },
304 group_thousands(int_part),
305 frac
306 )
307 }
308
309 /// Format a whole count with thousands separators (`1,000`).
310 fn fmt_count(n: f64) -> String {
311 group_thousands(&format!("{:.0}", n.round().abs()))
312 }
313
314 /// Total fees as a percentage of gross, to one decimal. `--` when there is no
315 /// gross to take a percentage of.
316 fn fmt_rate(gross: f64, net: f64) -> String {
317 if gross <= 0.0 {
318 return "--".to_string();
319 }
320 format!("{:.1}%", (gross - net) / gross * 100.0)
321 }
322
323 /// Insert commas every three digits from the right into a bare integer string.
324 fn group_thousands(digits: &str) -> String {
325 let bytes = digits.as_bytes();
326 let len = bytes.len();
327 let mut out = String::with_capacity(len + len / 3);
328 for (i, b) in bytes.iter().enumerate() {
329 if i > 0 && (len - i).is_multiple_of(3) {
330 out.push(',');
331 }
332 out.push(*b as char);
333 }
334 out
335 }
336
337 #[cfg(test)]
338 mod tests {
339 use super::*;
340
341 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
342
343 fn loaded() -> FeeCalculator {
344 FeeCalculator::load(ASSUMPTIONS_PATH)
345 }
346
347 /// Float equality for values that are exactly a clamp bound or a copied
348 /// default, spelled with a tolerance so `float_cmp` stays happy.
349 #[track_caller]
350 fn approx(got: f64, want: f64, what: &str) {
351 assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}");
352 }
353
354 /// The canonical toml loads with the fee constants the checkout uses.
355 #[test]
356 fn loads_from_canonical_assumptions() {
357 let c = loaded();
358 assert!((c.stripe.percent - 0.029).abs() < 1e-9);
359 assert!((c.stripe.fixed - 0.30).abs() < 1e-9);
360 assert!(c.defaults.item_price > 0.0);
361 assert!(c.defaults.sales_per_month > 0.0);
362 assert!(c.defaults.other_pct > 0.0);
363 }
364
365 /// Both nets at a round scenario: 40 sales of $25 is $1,000 gross, the
366 /// other platform on 12.6% + $0.30.
367 ///
368 /// mnw = 1000 - 29 - 12 - 16 = 943
369 /// other = 1000 - 126 - 12 = 862
370 #[test]
371 fn pins_both_nets() {
372 let c = loaded();
373 let out = c.compute(Inputs {
374 item_price: 25.0,
375 sales_per_month: 40.0,
376 tier_cost: 16.0,
377 other_pct: 0.126,
378 other_per_sale: 0.30,
379 });
380 assert_eq!(out.gross, "$1,000.00");
381 assert_eq!(out.mnw_keep, "$943.00");
382 assert_eq!(out.other_keep, "$862.00");
383 assert_eq!(out.verdict, Verdict::MnwAhead);
384 assert_eq!(out.mnw_rate, "5.7%");
385 assert_eq!(out.other_rate, "13.8%");
386 }
387
388 /// The crossover is exact, not searched for: per-sale gain is
389 /// 25*(0.126-0.029) + (0.30-0.30) = 2.425, so $16 of tier is made back at
390 /// 16/2.425 = 6.598 sales, and the copy rounds up to 7.
391 #[test]
392 fn crossover_is_where_the_flat_fee_is_repaid() {
393 let c = loaded();
394 let i = Inputs {
395 item_price: 25.0,
396 sales_per_month: 3.0,
397 tier_cost: 16.0,
398 other_pct: 0.126,
399 other_per_sale: 0.30,
400 };
401 let out = c.compute(i);
402 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
403 let note = out.crossover_note.expect("crossover note");
404 assert!(note.contains("7 sales a month"), "note was: {note}");
405
406 // One sale either side of the rounded crossover confirms the flip.
407 let below = c.compute(Inputs {
408 sales_per_month: 6.0,
409 ..i
410 });
411 assert_eq!(below.verdict, Verdict::OtherAheadForNow);
412 let above = c.compute(Inputs {
413 sales_per_month: 7.0,
414 ..i
415 });
416 assert_eq!(above.verdict, Verdict::MnwAhead);
417 }
418
419 /// HONESTY CONTRACT. A platform charging less per sale than bare payment
420 /// processing wins at every volume, and the calculator has to say so
421 /// instead of promising a crossover that does not exist.
422 #[test]
423 fn no_crossover_when_the_other_platform_is_cheaper_per_sale() {
424 let c = loaded();
425 let out = c.compute(Inputs {
426 item_price: 5.0,
427 sales_per_month: 500.0,
428 tier_cost: 16.0,
429 // 1% and no flat fee: below Stripe's own rate.
430 other_pct: 0.01,
431 other_per_sale: 0.0,
432 });
433 assert_eq!(out.verdict, Verdict::OtherAhead);
434 let note = out.crossover_note.expect("no-crossover note");
435 assert!(note.contains("no amount of volume"), "note was: {note}");
436 // No volume closes the gap, so there is no crossover to point at.
437 assert!(out.crossover_sales.is_none());
438 }
439
440 /// A free tier has nothing to repay, so MNW leads from the first sale and
441 /// there is no crossover to draw.
442 #[test]
443 fn zero_tier_cost_has_no_crossover() {
444 let out = loaded().compute(Inputs {
445 item_price: 25.0,
446 sales_per_month: 40.0,
447 tier_cost: 0.0,
448 other_pct: 0.126,
449 other_per_sale: 0.30,
450 });
451 assert_eq!(out.verdict, Verdict::MnwAhead);
452 assert!(out.crossover_note.is_none());
453 assert!(out.crossover_sales.is_none());
454 }
455
456 /// Zero sales is a real answer, not a placeholder: the subscription still
457 /// costs what it costs, and the page shows the negative.
458 #[test]
459 fn zero_sales_shows_the_subscription_as_a_loss() {
460 let out = loaded().compute(Inputs {
461 item_price: 25.0,
462 sales_per_month: 0.0,
463 tier_cost: 16.0,
464 other_pct: 0.126,
465 other_per_sale: 0.30,
466 });
467 assert_eq!(out.gross, "$0.00");
468 assert_eq!(out.mnw_keep, "-$16.00");
469 assert_eq!(out.other_keep, "$0.00");
470 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
471 assert_eq!(out.mnw_rate, "--");
472 }
473
474 /// Identical fee structures land on Even rather than on either side.
475 #[test]
476 fn identical_terms_are_even() {
477 let out = loaded().compute(Inputs {
478 item_price: 25.0,
479 sales_per_month: 40.0,
480 tier_cost: 0.0,
481 other_pct: 0.029,
482 other_per_sale: 0.30,
483 });
484 assert_eq!(out.verdict, Verdict::Even);
485 assert_eq!(out.headline, "The two come out the same here");
486 }
487
488 /// Out-of-range and garbage dials clamp instead of 400ing or rendering
489 /// nonsense. Non-finite falls back to the default, not to zero.
490 #[test]
491 fn sanitize_clamps_every_dial() {
492 let c = loaded();
493 let s = c.sanitize(Inputs {
494 item_price: -5.0,
495 sales_per_month: 1e12,
496 tier_cost: -1.0,
497 other_pct: 4.0,
498 other_per_sale: f64::NAN,
499 });
500 approx(s.item_price, 0.0, "item_price clamps up to the floor");
501 approx(
502 s.sales_per_month,
503 MAX_SALES,
504 "sales clamp down to the ceiling",
505 );
506 approx(s.tier_cost, 0.0, "tier cost cannot go negative");
507 approx(
508 s.other_pct,
509 MAX_OTHER_PCT,
510 "their cut clamps to the ceiling",
511 );
512 approx(
513 s.other_per_sale,
514 c.defaults.other_per_sale,
515 "NaN falls back to the default, not to zero",
516 );
517
518 let inf = c.sanitize(Inputs {
519 item_price: f64::INFINITY,
520 sales_per_month: f64::NEG_INFINITY,
521 tier_cost: 16.0,
522 other_pct: f64::NAN,
523 other_per_sale: 0.30,
524 });
525 approx(inf.item_price, c.defaults.item_price, "+inf price");
526 approx(
527 inf.sales_per_month,
528 c.defaults.sales_per_month,
529 "-inf sales",
530 );
531 approx(inf.other_pct, c.defaults.other_pct, "NaN cut");
532 }
533
534 /// A crossover is a count of sales, so it is positive and finite wherever
535 /// there is one, at every dial position the inputs allow.
536 ///
537 /// This replaces `scale_segments_tile_the_axis`, which checked that two
538 /// hand-computed bar widths summed to 100. The widths went with the
539 /// template that positioned them; the property worth holding is the one the
540 /// panel now states, since a meter drawn against a zero or negative total
541 /// says nothing a reader can act on.
542 #[test]
543 fn a_crossover_is_a_positive_count_of_sales_wherever_there_is_one() {
544 let c = loaded();
545 for sales in [0.0, 1.0, 7.0, 40.0, 5_000.0, MAX_SALES] {
546 for price in [0.0, 1.0, 25.0, MAX_ITEM_PRICE] {
547 for pct in [0.0, 0.029, 0.126, MAX_OTHER_PCT] {
548 let out = c.compute(Inputs {
549 item_price: price,
550 sales_per_month: sales,
551 tier_cost: 16.0,
552 other_pct: pct,
553 other_per_sale: 0.30,
554 });
555 if let Some(crossover) = out.crossover_sales {
556 assert!(
557 crossover.is_finite() && crossover > 0.0,
558 "crossover {crossover} at price {price}, {sales} sales, pct {pct}"
559 );
560 }
561 approx(
562 out.sales_per_month,
563 sales,
564 "the panel is told a volume the reader did not set",
565 );
566 }
567 }
568 }
569 }
570
571 #[test]
572 fn money_and_count_formatting() {
573 assert_eq!(fmt_money(943.0), "$943.00");
574 assert_eq!(fmt_money(1234.5), "$1,234.50");
575 assert_eq!(fmt_money(-12.34), "-$12.34");
576 assert_eq!(fmt_money(-0.001), "$0.00");
577 assert_eq!(fmt_count(1000.0), "1,000");
578 assert_eq!(fmt_rate(1000.0, 943.0), "5.7%");
579 assert_eq!(fmt_rate(0.0, -16.0), "--");
580 }
581 }
582