Skip to main content

max / makenotwork

Restore pricing_comparison, wrongly deleted in 65844498 65844498 was meant to carry the regenerated openapi.json alone. It also committed two staged deletions that were sitting in the index as part of an in-flight rename to fee_calculator, which left lib.rs declaring a module whose file was gone and broke the build on main. Restores both files at their 65844498^ content. The rename lands when its author commits it, together with the lib.rs edit that goes with it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:56 UTC
Signed with PGP, not checked
Commit: 6fe9ee0f138c2e258d6bfca11ba871367f076873
Parent: 6584449
2 files changed, +456 insertions, -0 deletions
@@ -1,0 +1,403 @@
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: "Makenotwork".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, "Makenotwork"), "$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 + }
@@ -1,0 +1,53 @@
1 + {# Pricing comparison partial, server-rendered, swapped into /pricing via
2 + HTMX from GET /pricing/compare as the revenue/tier inputs change. Every
3 + value here is preformatted in `pricing_comparison::ComparisonResult`; this
4 + template does no arithmetic. The math lives in Rust, sourced from
5 + `docs/business/assumptions.toml`, and is pinned by
6 + `pricing_comparison::tests`. #}
7 + <div class="tier-section" id="results">
8 + <h2 class="section-label">You keep</h2>
9 + <div class="mnw-summary">
10 + <div class="mnw-keep" id="mnw-keep">{{ comparison.mnw_keep }}</div>
11 + <div class="mnw-detail" id="mnw-detail">{{ comparison.mnw_detail }}</div>
12 + </div>
13 + </div>
14 +
15 + <div class="tier-section">
16 + <h2 class="section-label">Platform comparison</h2>
17 + <table class="data-table" id="comparison-table" aria-label="Platform fee comparison">
18 + <thead>
19 + <tr>
20 + <th>Platform</th>
21 + <th>Fee structure</th>
22 + <th>You keep</th>
23 + <th>vs MNW</th>
24 + </tr>
25 + </thead>
26 + <tbody id="comparison-body">
27 + {% for row in comparison.rows %}
28 + <tr{% if row.is_mnw %} class="highlight"{% endif %}>
29 + <td>
30 + {%- if row.is_mnw -%}
31 + {{ row.name }}
32 + {%- else -%}
33 + <a href="{{ row.url }}" target="_blank" rel="noopener">{{ row.name }}</a>
34 + {%- endif -%}
35 + </td>
36 + <td>{{ row.fee_label }}</td>
37 + <td>{{ row.keep }}</td>
38 + <td{% if !row.diff_class.is_empty() %} class="{{ row.diff_class }}"{% endif %}>{{ row.diff }}</td>
39 + </tr>
40 + {% endfor %}
41 + </tbody>
42 + </table>
43 + <p class="pricing-disclaimer">Fee structures sourced from each platform's public pricing page. Actual fees may vary by plan, region, or payment method. Verify on each platform's site before making decisions.</p>
44 + <p class="pricing-disclaimer"><strong>Selling small-ticket items?</strong> Payment processors charge a fixed fee per transaction (~$0.30) that hits harder on $1-5 sales. This is an industry-wide constraint, not specific to any platform. Bundling items into collections lets your fans buy in groups at a single transaction cost instead of paying per-item processing fees.</p>
45 + </div>
46 +
47 + {% match comparison.breakeven %}
48 + {% when Some with (note) %}
49 + <div class="tier-section" id="breakeven-section">
50 + <div class="breakeven-note" id="breakeven-note">{{ note }}</div>
51 + </div>
52 + {% when None %}
53 + {% endmatch %}