Skip to main content

max / makenotwork

server: compute the pricing comparison from assumptions.toml The public /pricing page hardcoded Stripe's fee constants and nine competitor fee models in page-pricing.js, with no server backing and no test — a public revenue claim that could silently drift from the real checkout rate. Move the fee models into assumptions.toml ([pricing_calc] + [[competitors]]) and compute the comparison in Rust (pricing_comparison.rs), parsed at startup alongside the other assumptions. The page now recomputes server-side via a debounced HTMX GET /pricing/compare that swaps the rendered table; no fee math runs in the browser. Six tests pin every competitor's net at representative revenues to the exact figures the JS produced, plus the Bandcamp tier threshold and zero-revenue placeholders. Delete page-pricing.js; move tier-selection styling to CSS :has().
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-08 01:46 UTC
Signed with PGP, not checked
Commit: 446b9399fc446c15e3c1130bdb72d10332e93179
Parent: d070e8f
13 files changed, +647 insertions, -324 deletions
@@ -4389,6 +4389,7 @@
4389 4389 "tokio",
4390 4390 "tokio-stream",
4391 4391 "tokio-util",
4392 + "toml",
4392 4393 "totp-rs",
4393 4394 "tower",
4394 4395 "tower-http",
@@ -23,6 +23,7 @@
23 23 utoipa-axum = "0.2"
24 24 serde = { version = "1.0.228", features = ["derive"] }
25 25 serde_json = "1.0.149"
26 + toml = "0.8"
26 27 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "net", "signal"] }
27 28 tokio-stream = { version = "0.1", features = ["sync"] }
28 29 tokio-util = { version = "0.7", features = ["io"] }
@@ -30,6 +30,7 @@
30 30 pub mod wam_client;
31 31 pub mod payments;
32 32 pub mod pricing;
33 + pub mod pricing_comparison;
33 34 pub mod synckit_billing;
34 35 pub mod scheduler;
35 36 pub mod routes;
@@ -92,6 +93,9 @@
92 93 pub tier_prices: tier_prices::TierPrices,
93 94 pub cost_allocation: tier_prices::CostAllocation,
94 95 pub runway_config: tier_prices::RunwayConfig,
96 + /// Public `/pricing` comparison model (competitor fee tables + Stripe
97 + /// fees), parsed from `assumptions.toml` at startup.
98 + pub pricing_comparison: pricing_comparison::PricingComparison,
95 99 pub scanner: Option<Arc<ScanPipeline>>,
96 100 pub webauthn: Arc<Webauthn>,
97 101 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
@@ -403,6 +403,7 @@
403 403 makenotwork::tier_prices::CostAllocation::from_assumptions(&assumptions, &tp)
404 404 },
405 405 runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions),
406 + pricing_comparison: makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path),
406 407 scanner,
407 408 webauthn,
408 409 syntax,
@@ -313,3 +313,97 @@
313 313 # sprint close — bump if burn rises (Year 2 $5K salary) or funding changes.
314 314 quarters = 17
315 315 last_updated_iso = "2026-06-04"
316 +
317 +
318 + # ─── Pricing calculator (canonical: this file + stripe_fees.md) ───────────
319 + #
320 + # Powers the public /pricing comparison. Consumed by MNW's
321 + # `src/pricing_comparison.rs` (parsed directly from this file, not through
322 + # docengine's scalar lookup — arrays of tables aren't substitutable). The
323 + # net-revenue formula and every competitor's parameters live HERE, not in
324 + # client JS, so the public "you keep more" claim has one source of truth and
325 + # a pinning test (`pricing_comparison::tests`). Stripe fees are read from the
326 + # [stripe] block above (percent, fixed).
327 + #
328 + # Per-competitor net(revenue) is computed as:
329 + # txns = max(1, round(revenue / pricing_calc.avg_transaction))
330 + # pct = platform_pct_over if (platform_pct_threshold set AND rev > it)
331 + # else platform_pct
332 + # ap = revenue * (1 - pct) - txns * platform_per_tx
333 + # net = { none: ap
334 + # , percent: ap - ap*stripe.percent
335 + # , percent_and_fixed: ap - ap*stripe.percent - txns*stripe.fixed }
336 + # - flat_monthly
337 + # MNW's own row = platform_pct 0, percent_and_fixed, flat_monthly = tier price.
338 + #
339 + # Fee structures are each platform's public pricing as of 2026-06. Verify at
340 + # the linked page before editing; a change here updates the live page + test.
341 + [pricing_calc]
342 + avg_transaction = 25 # assumed average sale, for per-tx fee estimation
343 +
344 + [[competitors]]
345 + name = "Patreon"
346 + url = "https://www.patreon.com/pricing"
347 + fee_label = "10% + ~3% + $0.30"
348 + platform_pct = 0.10
349 + stripe_mode = "percent_and_fixed"
350 +
351 + [[competitors]]
352 + name = "Gumroad"
353 + url = "https://gumroad.com/pricing"
354 + fee_label = "10% + $0.50/tx + ~3%"
355 + platform_pct = 0.10
356 + platform_per_tx = 0.50
357 + stripe_mode = "percent"
358 +
359 + [[competitors]]
360 + name = "Bandcamp"
361 + url = "https://bandcamp.com/pricing"
362 + fee_label = "15% (10% after $5k) + ~3%"
363 + platform_pct = 0.15
364 + platform_pct_over = 0.10
365 + platform_pct_threshold = 5000
366 + stripe_mode = "percent"
367 +
368 + [[competitors]]
369 + name = "Ko-fi Free"
370 + url = "https://ko-fi.com/pricing"
371 + fee_label = "0% tips, 5% shop/memberships + ~3%"
372 + platform_pct = 0.05
373 + stripe_mode = "percent"
374 +
375 + [[competitors]]
376 + name = "Ko-fi Gold"
377 + url = "https://ko-fi.com/pricing"
378 + fee_label = "$12/mo + 0% + ~3%"
379 + flat_monthly = 12
380 + stripe_mode = "percent_and_fixed"
381 +
382 + [[competitors]]
383 + name = "Itch.io"
384 + url = "https://itch.io/docs/creators/faq"
385 + fee_label = "10% default + ~3%"
386 + platform_pct = 0.10
387 + stripe_mode = "percent"
388 +
389 + [[competitors]]
390 + name = "Lemon Squeezy"
391 + url = "https://www.lemonsqueezy.com/pricing"
392 + fee_label = "5% + $0.50/tx (incl. processing)"
393 + platform_pct = 0.05
394 + platform_per_tx = 0.50
395 + stripe_mode = "none"
396 +
397 + [[competitors]]
398 + name = "Buy Me a Coffee"
399 + url = "https://www.buymeacoffee.com/about"
400 + fee_label = "5% + ~3%"
401 + platform_pct = 0.05
402 + stripe_mode = "percent"
403 +
404 + [[competitors]]
405 + name = "Substack"
406 + url = "https://substack.com/going-paid"
407 + fee_label = "10% on paid subs"
408 + platform_pct = 0.10
409 + stripe_mode = "percent"
@@ -143,6 +143,7 @@
143 143 DocIndexTemplate,
144 144 // Pricing calculator
145 145 PricingTemplate,
146 + PricingComparisonPartial,
146 147 // Platform economics + runway disclosure
147 148 EconomicsTemplate,
148 149 // Use cases
@@ -9,76 +9,63 @@
9 9 <h1 class="page-title">Pricing Calculator<span class="dot">.</span></h1>
10 10 <p class="tagline">See what you keep on every sale<span class="dot">.</span></p>
11 11
12 - <div class="tier-section">
13 - <h2 class="section-label">Monthly revenue</h2>
14 - <div class="pricing-input-wrap">
15 - <span class="pricing-currency">$</span>
16 - <input type="number" id="revenue" class="pricing-input" value="1000" min="0" max="999999" step="50" aria-label="Monthly revenue in dollars">
17 - <span class="pricing-period">/mo</span>
12 + {# The calculator recomputes server-side: on revenue/tier change HTMX
13 + GETs /pricing/compare and swaps #comparison-panel. No fee math runs
14 + in the browser — it all lives in `pricing_comparison.rs`, sourced
15 + from `assumptions.toml`. The inputs sit outside the swap target so
16 + focus and the typed value survive each recompute. #}
17 + <div id="pricing-calculator"
18 + hx-get="/pricing/compare"
19 + hx-trigger="input changed delay:300ms from:#revenue, change from:input[name='tier']"
20 + hx-include="#revenue, input[name='tier']:checked"
21 + hx-target="#comparison-panel"
22 + hx-swap="innerHTML">
23 +
24 + <div class="tier-section">
25 + <h2 class="section-label">Monthly revenue</h2>
26 + <div class="pricing-input-wrap">
27 + <span class="pricing-currency">$</span>
28 + <input type="number" id="revenue" name="revenue" class="pricing-input" value="1000" min="0" max="999999" step="50" aria-label="Monthly revenue in dollars">
29 + <span class="pricing-period">/mo</span>
30 + </div>
18 31 </div>
19 - </div>
20 32
21 - <div class="tier-section">
22 - <h2 class="section-label">Your content tier</h2>
23 - <p class="tier-intro">Every tier is the complete platform: profile, project pages, forum, discovery, memberships, analytics, full data export. The tier picks the file-size envelope, not the feature set.</p>
24 - <div class="tier-selector" role="radiogroup" aria-label="Content tier">
25 - <label class="tier-card tier-option is-selected">
26 - <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked>
27 - <div class="tier-name">Basic</div>
28 - <div class="tier-price">${{ tier_prices.basic_std }}/mo</div>
29 - <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div>
30 - </label>
31 - <label class="tier-card tier-option">
32 - <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}">
33 - <div class="tier-name">Small Files</div>
34 - <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div>
35 - <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div>
36 - </label>
37 - <label class="tier-card tier-option">
38 - <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}">
39 - <div class="tier-name">Big Files</div>
40 - <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div>
41 - <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div>
42 - </label>
43 - <label class="tier-card tier-option">
44 - <input type="radio" name="tier" value="{{ tier_prices.everything_std }}">
45 - <div class="tier-name">Everything</div>
46 - <div class="tier-price">${{ tier_prices.everything_std }}/mo</div>
47 - <div class="tier-desc">Big Files envelope plus first access to high-cost features as they ship.</div>
48 - </label>
33 + <div class="tier-section">
34 + <h2 class="section-label">Your content tier</h2>
35 + <p class="tier-intro">Every tier is the complete platform: profile, project pages, forum, discovery, memberships, analytics, full data export. The tier picks the file-size envelope, not the feature set.</p>
36 + <div class="tier-selector" role="radiogroup" aria-label="Content tier">
37 + <label class="tier-card tier-option">
38 + <input type="radio" name="tier" value="{{ tier_prices.basic_std }}" checked>
39 + <div class="tier-name">Basic</div>
40 + <div class="tier-price">${{ tier_prices.basic_std }}/mo</div>
41 + <div class="tier-desc">{{ tier_prices.basic_per_file }}/file, {{ tier_prices.basic_total }} total. Fits text, blogs, newsletters.</div>
42 + </label>
43 + <label class="tier-card tier-option">
44 + <input type="radio" name="tier" value="{{ tier_prices.small_files_std }}">
45 + <div class="tier-name">Small Files</div>
46 + <div class="tier-price">${{ tier_prices.small_files_std }}/mo</div>
47 + <div class="tier-desc">{{ tier_prices.small_files_per_file }}/file, {{ tier_prices.small_files_total }} total. Fits audio, plugins, binaries.</div>
48 + </label>
49 + <label class="tier-card tier-option">
50 + <input type="radio" name="tier" value="{{ tier_prices.big_files_std }}">
51 + <div class="tier-name">Big Files</div>
52 + <div class="tier-price">${{ tier_prices.big_files_std }}/mo</div>
53 + <div class="tier-desc">{{ tier_prices.big_files_per_file }}/file, {{ tier_prices.big_files_total }} total. Fits video, games, large software.</div>
54 + </label>
55 + <label class="tier-card tier-option">
56 + <input type="radio" name="tier" value="{{ tier_prices.everything_std }}">
57 + <div class="tier-name">Everything</div>
58 + <div class="tier-price">${{ tier_prices.everything_std }}/mo</div>
59 + <div class="tier-desc">Big Files envelope plus first access to high-cost features as they ship.</div>
60 + </label>
61 + </div>
49 62 </div>
50 - </div>
51 63
52 - <div class="tier-section" id="results">
53 - <h2 class="section-label">You keep</h2>
54 - <div class="mnw-summary">
55 - <div class="mnw-keep" id="mnw-keep">$943.00</div>
56 - <div class="mnw-detail" id="mnw-detail">of every $1,000 after processing fees (~3%) and ${{ tier_prices.basic_std }}/mo membership</div>
64 + <div id="comparison-panel">
65 + {% include "partials/pricing_comparison.html" %}
57 66 </div>
58 67 </div>
59 68
60 - <div class="tier-section">
61 - <h2 class="section-label">Platform comparison</h2>
62 - <table class="data-table" id="comparison-table" aria-label="Platform fee comparison">
63 - <thead>
64 - <tr>
65 - <th>Platform</th>
66 - <th>Fee structure</th>
67 - <th>You keep</th>
68 - <th>vs MNW</th>
69 - </tr>
70 - </thead>
71 - <tbody id="comparison-body">
72 - </tbody>
73 - </table>
74 - <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>
75 - <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>
76 - </div>
77 -
78 - <div class="tier-section hidden" id="breakeven-section">
79 - <div class="breakeven-note" id="breakeven-note"></div>
80 - </div>
81 -
82 69 <div class="tier-section cost-allocation" id="cost-allocation">
83 70 <h2 class="section-label">Where your tier fee goes</h2>
84 71 <p class="cost-allocation-intro">You're paying for a platform built to be fair and built to last. Here's where each monthly fee goes, averaged across creators on that tier. Hover any segment for detail.</p>
@@ -119,8 +106,3 @@
119 106 </div>
120 107 </div>
121 108 {% endblock %}
122 -
123 - {% block scripts %}
124 - <div id="page-pricing-cfg" hidden data-basic-std="{{ tier_prices.basic_std }}"></div>
125 - <script src="/static/page-pricing.js?v=0623" defer></script>
126 - {% endblock %}