Skip to main content

max / makenotwork

22.1 KB · 600 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 crossover: crossover.map(|at| Crossover {
249 sales: i.sales_per_month.round().max(0.0) as u32,
250 at: at.ceil().max(1.0) as u32,
251 }),
252 }
253 }
254 }
255
256 /// Which side of the crossover the entered numbers land on.
257 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
258 pub enum Verdict {
259 /// MNW keeps more at these numbers.
260 MnwAhead,
261 /// The two are within half a cent.
262 Even,
263 /// The other platform keeps more, but volume would close it.
264 OtherAheadForNow,
265 /// The other platform keeps more at every volume.
266 OtherAhead,
267 }
268
269 /// Rendered calculator output, handed to the page and the HTMX partial.
270 #[derive(Debug, Clone)]
271 pub struct Outcome {
272 /// Monthly gross before anyone's fees.
273 pub gross: String,
274 /// Monthly take-home on MNW. Negative when the tier costs more than the
275 /// sales bring in, which is rendered rather than clamped away.
276 pub mnw_keep: String,
277 /// Monthly take-home on the described platform.
278 pub other_keep: String,
279 /// Effective total fee as a percentage of gross, e.g. `4.2%`.
280 pub mnw_rate: String,
281 pub other_rate: String,
282 /// One-line statement of who comes out ahead at these numbers.
283 pub headline: String,
284 pub verdict: Verdict,
285 /// Where the crossover sits, or why there isn't one.
286 pub crossover_note: Option<String>,
287 /// Where the entered volume sits against the crossover. `None` when our
288 /// per-sale advantage is zero or negative, which is the case no volume
289 /// closes: there is then no set for the volume to be a proportion of, and
290 /// [`Outcome::crossover_note`] says so in words instead.
291 pub crossover: Option<Crossover>,
292 }
293
294 /// The entered volume against the crossover, as the two whole counts a meter
295 /// is drawn from.
296 ///
297 /// Whole counts because a proportion is read by a person and a meter takes two
298 /// `u32`s. They are resolved here rather than where the panel is described,
299 /// because a description admits no arithmetic and this is a rounding and two
300 /// casts.
301 #[derive(Debug, Clone, Copy)]
302 pub struct Crossover {
303 /// The volume the dials were set to. Exact, not a second rounding:
304 /// `sanitize` already rounded it to a whole count and clamped it to
305 /// `MAX_SALES`.
306 pub sales: u32,
307 /// Sales a month at which we become the cheaper place, rounded up. At least
308 /// one, because a meter drawn against a zero total says nothing a reader
309 /// can act on.
310 pub at: u32,
311 }
312
313 /// Format dollars as `$1,234.56`, with the sign ahead of the `$`.
314 fn fmt_money(n: f64) -> String {
315 let rounded = (n * 100.0).round() / 100.0;
316 let s = format!("{:.2}", rounded.abs());
317 let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
318 format!(
319 "{}${}.{}",
320 if rounded < 0.0 { "-" } else { "" },
321 group_thousands(int_part),
322 frac
323 )
324 }
325
326 /// Format a whole count with thousands separators (`1,000`).
327 fn fmt_count(n: f64) -> String {
328 group_thousands(&format!("{:.0}", n.round().abs()))
329 }
330
331 /// Total fees as a percentage of gross, to one decimal. `--` when there is no
332 /// gross to take a percentage of.
333 fn fmt_rate(gross: f64, net: f64) -> String {
334 if gross <= 0.0 {
335 return "--".to_string();
336 }
337 format!("{:.1}%", (gross - net) / gross * 100.0)
338 }
339
340 /// Insert commas every three digits from the right into a bare integer string.
341 fn group_thousands(digits: &str) -> String {
342 let bytes = digits.as_bytes();
343 let len = bytes.len();
344 let mut out = String::with_capacity(len + len / 3);
345 for (i, b) in bytes.iter().enumerate() {
346 if i > 0 && (len - i).is_multiple_of(3) {
347 out.push(',');
348 }
349 out.push(*b as char);
350 }
351 out
352 }
353
354 #[cfg(test)]
355 mod tests {
356 use super::*;
357
358 const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
359
360 fn loaded() -> FeeCalculator {
361 FeeCalculator::load(ASSUMPTIONS_PATH)
362 }
363
364 /// Float equality for values that are exactly a clamp bound or a copied
365 /// default, spelled with a tolerance so `float_cmp` stays happy.
366 #[track_caller]
367 fn approx(got: f64, want: f64, what: &str) {
368 assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}");
369 }
370
371 /// The canonical toml loads with the fee constants the checkout uses.
372 #[test]
373 fn loads_from_canonical_assumptions() {
374 let c = loaded();
375 assert!((c.stripe.percent - 0.029).abs() < 1e-9);
376 assert!((c.stripe.fixed - 0.30).abs() < 1e-9);
377 assert!(c.defaults.item_price > 0.0);
378 assert!(c.defaults.sales_per_month > 0.0);
379 assert!(c.defaults.other_pct > 0.0);
380 }
381
382 /// Both nets at a round scenario: 40 sales of $25 is $1,000 gross, the
383 /// other platform on 12.6% + $0.30.
384 ///
385 /// mnw = 1000 - 29 - 12 - 16 = 943
386 /// other = 1000 - 126 - 12 = 862
387 #[test]
388 fn pins_both_nets() {
389 let c = loaded();
390 let out = c.compute(Inputs {
391 item_price: 25.0,
392 sales_per_month: 40.0,
393 tier_cost: 16.0,
394 other_pct: 0.126,
395 other_per_sale: 0.30,
396 });
397 assert_eq!(out.gross, "$1,000.00");
398 assert_eq!(out.mnw_keep, "$943.00");
399 assert_eq!(out.other_keep, "$862.00");
400 assert_eq!(out.verdict, Verdict::MnwAhead);
401 assert_eq!(out.mnw_rate, "5.7%");
402 assert_eq!(out.other_rate, "13.8%");
403 }
404
405 /// The crossover is exact, not searched for: per-sale gain is
406 /// 25*(0.126-0.029) + (0.30-0.30) = 2.425, so $16 of tier is made back at
407 /// 16/2.425 = 6.598 sales, and the copy rounds up to 7.
408 #[test]
409 fn crossover_is_where_the_flat_fee_is_repaid() {
410 let c = loaded();
411 let i = Inputs {
412 item_price: 25.0,
413 sales_per_month: 3.0,
414 tier_cost: 16.0,
415 other_pct: 0.126,
416 other_per_sale: 0.30,
417 };
418 let out = c.compute(i);
419 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
420 let note = out.crossover_note.expect("crossover note");
421 assert!(note.contains("7 sales a month"), "note was: {note}");
422
423 // One sale either side of the rounded crossover confirms the flip.
424 let below = c.compute(Inputs {
425 sales_per_month: 6.0,
426 ..i
427 });
428 assert_eq!(below.verdict, Verdict::OtherAheadForNow);
429 let above = c.compute(Inputs {
430 sales_per_month: 7.0,
431 ..i
432 });
433 assert_eq!(above.verdict, Verdict::MnwAhead);
434 }
435
436 /// HONESTY CONTRACT. A platform charging less per sale than bare payment
437 /// processing wins at every volume, and the calculator has to say so
438 /// instead of promising a crossover that does not exist.
439 #[test]
440 fn no_crossover_when_the_other_platform_is_cheaper_per_sale() {
441 let c = loaded();
442 let out = c.compute(Inputs {
443 item_price: 5.0,
444 sales_per_month: 500.0,
445 tier_cost: 16.0,
446 // 1% and no flat fee: below Stripe's own rate.
447 other_pct: 0.01,
448 other_per_sale: 0.0,
449 });
450 assert_eq!(out.verdict, Verdict::OtherAhead);
451 let note = out.crossover_note.expect("no-crossover note");
452 assert!(note.contains("no amount of volume"), "note was: {note}");
453 // No volume closes the gap, so there is no crossover to point at.
454 assert!(out.crossover.is_none());
455 }
456
457 /// A free tier has nothing to repay, so MNW leads from the first sale and
458 /// there is no crossover to draw.
459 #[test]
460 fn zero_tier_cost_has_no_crossover() {
461 let out = loaded().compute(Inputs {
462 item_price: 25.0,
463 sales_per_month: 40.0,
464 tier_cost: 0.0,
465 other_pct: 0.126,
466 other_per_sale: 0.30,
467 });
468 assert_eq!(out.verdict, Verdict::MnwAhead);
469 assert!(out.crossover_note.is_none());
470 assert!(out.crossover.is_none());
471 }
472
473 /// Zero sales is a real answer, not a placeholder: the subscription still
474 /// costs what it costs, and the page shows the negative.
475 #[test]
476 fn zero_sales_shows_the_subscription_as_a_loss() {
477 let out = loaded().compute(Inputs {
478 item_price: 25.0,
479 sales_per_month: 0.0,
480 tier_cost: 16.0,
481 other_pct: 0.126,
482 other_per_sale: 0.30,
483 });
484 assert_eq!(out.gross, "$0.00");
485 assert_eq!(out.mnw_keep, "-$16.00");
486 assert_eq!(out.other_keep, "$0.00");
487 assert_eq!(out.verdict, Verdict::OtherAheadForNow);
488 assert_eq!(out.mnw_rate, "--");
489 }
490
491 /// Identical fee structures land on Even rather than on either side.
492 #[test]
493 fn identical_terms_are_even() {
494 let out = loaded().compute(Inputs {
495 item_price: 25.0,
496 sales_per_month: 40.0,
497 tier_cost: 0.0,
498 other_pct: 0.029,
499 other_per_sale: 0.30,
500 });
501 assert_eq!(out.verdict, Verdict::Even);
502 assert_eq!(out.headline, "The two come out the same here");
503 }
504
505 /// Out-of-range and garbage dials clamp instead of 400ing or rendering
506 /// nonsense. Non-finite falls back to the default, not to zero.
507 #[test]
508 fn sanitize_clamps_every_dial() {
509 let c = loaded();
510 let s = c.sanitize(Inputs {
511 item_price: -5.0,
512 sales_per_month: 1e12,
513 tier_cost: -1.0,
514 other_pct: 4.0,
515 other_per_sale: f64::NAN,
516 });
517 approx(s.item_price, 0.0, "item_price clamps up to the floor");
518 approx(
519 s.sales_per_month,
520 MAX_SALES,
521 "sales clamp down to the ceiling",
522 );
523 approx(s.tier_cost, 0.0, "tier cost cannot go negative");
524 approx(
525 s.other_pct,
526 MAX_OTHER_PCT,
527 "their cut clamps to the ceiling",
528 );
529 approx(
530 s.other_per_sale,
531 c.defaults.other_per_sale,
532 "NaN falls back to the default, not to zero",
533 );
534
535 let inf = c.sanitize(Inputs {
536 item_price: f64::INFINITY,
537 sales_per_month: f64::NEG_INFINITY,
538 tier_cost: 16.0,
539 other_pct: f64::NAN,
540 other_per_sale: 0.30,
541 });
542 approx(inf.item_price, c.defaults.item_price, "+inf price");
543 approx(
544 inf.sales_per_month,
545 c.defaults.sales_per_month,
546 "-inf sales",
547 );
548 approx(inf.other_pct, c.defaults.other_pct, "NaN cut");
549 }
550
551 /// A crossover is a count of sales, so it is positive wherever there is
552 /// one, and the volume it is read against is the one the reader set.
553 ///
554 /// This replaces `scale_segments_tile_the_axis`, which checked that two
555 /// hand-computed bar widths summed to 100. The widths went with the
556 /// template that positioned them; the property worth holding is the one the
557 /// panel now states, since a meter drawn against a zero total says nothing
558 /// a reader can act on.
559 #[test]
560 fn a_crossover_is_a_positive_count_of_sales_wherever_there_is_one() {
561 let c = loaded();
562 for sales in [0.0, 1.0, 7.0, 40.0, 5_000.0, MAX_SALES] {
563 for price in [0.0, 1.0, 25.0, MAX_ITEM_PRICE] {
564 for pct in [0.0, 0.029, 0.126, MAX_OTHER_PCT] {
565 let out = c.compute(Inputs {
566 item_price: price,
567 sales_per_month: sales,
568 tier_cost: 16.0,
569 other_pct: pct,
570 other_per_sale: 0.30,
571 });
572 if let Some(crossover) = out.crossover {
573 assert!(
574 crossover.at >= 1,
575 "crossover at {} at price {price}, {sales} sales, pct {pct}",
576 crossover.at
577 );
578 approx(
579 f64::from(crossover.sales),
580 sales,
581 "the panel is told a volume the reader did not set",
582 );
583 }
584 }
585 }
586 }
587 }
588
589 #[test]
590 fn money_and_count_formatting() {
591 assert_eq!(fmt_money(943.0), "$943.00");
592 assert_eq!(fmt_money(1234.5), "$1,234.50");
593 assert_eq!(fmt_money(-12.34), "-$12.34");
594 assert_eq!(fmt_money(-0.001), "$0.00");
595 assert_eq!(fmt_count(1000.0), "1,000");
596 assert_eq!(fmt_rate(1000.0, 943.0), "5.7%");
597 assert_eq!(fmt_rate(0.0, -16.0), "--");
598 }
599 }
600