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
Commit: 446b9399fc446c15e3c1130bdb72d10332e93179
Parent: d070e8f
13 files changed, +647 insertions, -324 deletions
@@ -4389,6 +4389,7 @@ dependencies = [
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 @@ utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
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"] }
@@ -313,3 +313,97 @@ earnback = 33.46
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"
@@ -30,6 +30,7 @@ pub mod oauth_scope;
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 @@ pub struct AppState {
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 @@ async fn main() {
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,
@@ -0,0 +1,393 @@
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)
109 + .unwrap_or_else(|e| panic!("failed to parse pricing sections of assumptions.toml: {e}"));
110 + Self {
111 + stripe: t.stripe,
112 + calc: t.pricing_calc,
113 + competitors: t.competitors,
114 + }
115 + }
116 +
117 + /// Estimated number of transactions to clear `revenue` at the assumed
118 + /// average sale. Zero for non-positive revenue; otherwise at least one.
119 + fn estimate_transactions(&self, revenue: f64) -> f64 {
120 + if revenue <= 0.0 {
121 + return 0.0;
122 + }
123 + (revenue / self.calc.avg_transaction).round().max(1.0)
124 + }
125 +
126 + /// What a creator keeps on MNW's processing alone (before membership).
127 + fn stripe_net(&self, revenue: f64) -> f64 {
128 + if revenue <= 0.0 {
129 + return 0.0;
130 + }
131 + let txns = self.estimate_transactions(revenue);
132 + revenue - revenue * self.stripe.percent - txns * self.stripe.fixed
133 + }
134 +
135 + /// Net revenue a creator keeps on a competitor at `revenue`.
136 + fn competitor_net(&self, c: &Competitor, revenue: f64) -> f64 {
137 + if revenue <= 0.0 {
138 + return -c.flat_monthly;
139 + }
140 + let txns = self.estimate_transactions(revenue);
141 + let pct = match (c.platform_pct_over, c.platform_pct_threshold) {
142 + (Some(over), Some(threshold)) if revenue > threshold => over,
143 + _ => c.platform_pct,
144 + };
145 + let after_platform = revenue * (1.0 - pct) - txns * c.platform_per_tx;
146 + let after_processing = match c.stripe_mode {
147 + StripeMode::None => after_platform,
148 + StripeMode::Percent => after_platform - after_platform * self.stripe.percent,
149 + StripeMode::PercentAndFixed => {
150 + after_platform - after_platform * self.stripe.percent - txns * self.stripe.fixed
151 + }
152 + };
153 + after_processing - c.flat_monthly
154 + }
155 +
156 + /// Build the full comparison for a given monthly `revenue` and selected
157 + /// tier's monthly `tier_cost` (both in whole dollars). Rows are sorted by
158 + /// net kept, descending, with the MNW row flagged.
159 + pub fn compute(&self, revenue: f64, tier_cost: f64) -> ComparisonResult {
160 + let mnw_net = self.stripe_net(revenue) - tier_cost;
161 +
162 + struct Raw {
163 + name: String,
164 + url: String,
165 + fee_label: String,
166 + is_mnw: bool,
167 + net: f64,
168 + }
169 +
170 + let mut raws = Vec::with_capacity(self.competitors.len() + 1);
171 + raws.push(Raw {
172 + name: "Makenot.work".to_string(),
173 + url: String::new(),
174 + fee_label: format!("{}/mo flat + ~3% processing", fmt_money(tier_cost)),
175 + is_mnw: true,
176 + net: mnw_net,
177 + });
178 + for c in &self.competitors {
179 + raws.push(Raw {
180 + name: c.name.clone(),
181 + url: c.url.clone(),
182 + fee_label: c.fee_label.clone(),
183 + is_mnw: false,
184 + net: self.competitor_net(c, revenue),
185 + });
186 + }
187 + raws.sort_by(|a, b| b.net.partial_cmp(&a.net).unwrap_or(std::cmp::Ordering::Equal));
188 +
189 + let mut breakeven_any = false;
190 + let rows = raws
191 + .into_iter()
192 + .map(|r| {
193 + let keep = if revenue > 0.0 {
194 + fmt_money(r.net.max(0.0))
195 + } else {
196 + "--".to_string()
197 + };
198 + let (diff, diff_class) = if r.is_mnw {
199 + ("--".to_string(), String::new())
200 + } else if revenue > 0.0 {
201 + let d = mnw_net - r.net;
202 + if d > 0.005 {
203 + (format!("+{}", fmt_money(d)), "savings-negative".to_string())
204 + } else if d < -0.005 {
205 + breakeven_any = true;
206 + (fmt_money(d), "savings-positive".to_string())
207 + } else {
208 + ("$0.00".to_string(), String::new())
209 + }
210 + } else {
211 + ("--".to_string(), String::new())
212 + };
213 + ComparisonRow {
214 + name: r.name,
215 + url: r.url,
216 + fee_label: r.fee_label,
217 + is_mnw: r.is_mnw,
218 + keep,
219 + diff,
220 + diff_class,
221 + }
222 + })
223 + .collect();
224 +
225 + let breakeven = if breakeven_any && revenue > 0.0 {
226 + Some(format!(
227 + "At {}/mo, some platforms with percentage-only fees cost less than \
228 + MNW's flat rate. MNW becomes cheaper as your revenue grows.",
229 + fmt_whole(revenue)
230 + ))
231 + } else {
232 + None
233 + };
234 +
235 + ComparisonResult {
236 + mnw_keep: fmt_money(mnw_net.max(0.0)),
237 + mnw_detail: format!(
238 + "of every {} after processing fees (~3%) and {}/mo membership",
239 + fmt_whole(revenue),
240 + fmt_money(tier_cost)
241 + ),
242 + rows,
243 + breakeven,
244 + }
245 + }
246 + }
247 +
248 + /// Rendered comparison, handed to the pricing template / HTMX partial.
249 + #[derive(Debug, Clone)]
250 + pub struct ComparisonResult {
251 + /// Formatted "you keep" headline for the MNW row (never negative).
252 + pub mnw_keep: String,
253 + /// Sub-line under the headline.
254 + pub mnw_detail: String,
255 + /// All rows (MNW + competitors), sorted by net kept, descending.
256 + pub rows: Vec<ComparisonRow>,
257 + /// Breakeven note when a competitor beats MNW at this revenue, else `None`.
258 + pub breakeven: Option<String>,
259 + }
260 +
261 + /// One rendered comparison row. All display strings are preformatted so the
262 + /// template does no math.
263 + #[derive(Debug, Clone)]
264 + pub struct ComparisonRow {
265 + pub name: String,
266 + /// Empty for the MNW row (rendered as bold text, not a link).
267 + pub url: String,
268 + pub fee_label: String,
269 + pub is_mnw: bool,
270 + /// "--" when revenue is zero, else formatted dollars.
271 + pub keep: String,
272 + /// "--" / "$0.00" / "+$x" / "$-x".
273 + pub diff: String,
274 + /// CSS class for the diff cell; empty when none applies.
275 + pub diff_class: String,
276 + }
277 +
278 + /// Format dollars as `$1,234.56`, matching the retired JS `fmt`. Negative
279 + /// values render the sign after the `$` (`$-12.34`) to match exactly.
280 + fn fmt_money(n: f64) -> String {
281 + let neg = n < 0.0;
282 + let s = format!("{:.2}", n.abs());
283 + let (int_part, frac) = s.split_once('.').unwrap_or((s.as_str(), "00"));
284 + format!("${}{}.{}", if neg { "-" } else { "" }, group_thousands(int_part), frac)
285 + }
286 +
287 + /// Format dollars as a whole number with thousands separators (`$1,000`),
288 + /// matching the retired JS `fmtWhole`.
289 + fn fmt_whole(n: f64) -> String {
290 + format!("${}", group_thousands(&format!("{:.0}", n.round().abs())))
291 + }
292 +
293 + /// Insert commas every three digits from the right into a bare integer string.
294 + fn group_thousands(digits: &str) -> String {
295 + let bytes = digits.as_bytes();
296 + let len = bytes.len();
297 + let mut out = String::with_capacity(len + len / 3);
298 + for (i, b) in bytes.iter().enumerate() {
299 + if i > 0 && (len - i).is_multiple_of(3) {
300 + out.push(',');
301 + }
302 + out.push(*b as char);
303 + }
304 + out
305 + }
306 +
307 + #[cfg(test)]
308 + mod tests {
309 + use super::*;
310 +
311 + const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml";
312 +
313 + fn loaded() -> PricingComparison {
314 + PricingComparison::load(ASSUMPTIONS_PATH)
315 + }
316 +
317 + /// The canonical toml loads and carries all nine competitors.
318 + #[test]
319 + fn loads_from_canonical_assumptions() {
320 + let p = loaded();
321 + assert_eq!(p.competitors.len(), 9, "expected 9 competitors");
322 + assert!((p.stripe.percent - 0.029).abs() < 1e-9);
323 + assert!((p.stripe.fixed - 0.30).abs() < 1e-9);
324 + assert!((p.calc.avg_transaction - 25.0).abs() < 1e-9);
325 + }
326 +
327 + fn net_of(result: &ComparisonResult, name: &str) -> String {
328 + result
329 + .rows
330 + .iter()
331 + .find(|r| r.name == name)
332 + .unwrap_or_else(|| panic!("row {name} missing"))
333 + .keep
334 + .clone()
335 + }
336 +
337 + /// Pin the headline + every competitor's kept-amount at $1,000/mo, Basic
338 + /// tier ($16). These are the exact figures the retired page-pricing.js
339 + /// produced — the regression anchor the JS never had.
340 + #[test]
341 + fn pins_net_at_1000_basic() {
342 + let p = loaded();
343 + let r = p.compute(1000.0, 16.0);
344 + // stripe_net(1000) = 1000 - 29 - 40*0.30 = 959; minus $16 = 943.
345 + assert_eq!(r.mnw_keep, "$943.00");
346 + assert_eq!(net_of(&r, "Makenot.work"), "$943.00");
347 + assert_eq!(net_of(&r, "Patreon"), "$861.90"); // 900 - 26.1 - 12
348 + assert_eq!(net_of(&r, "Gumroad"), "$854.48"); // (900-20) - 25.52
349 + assert_eq!(net_of(&r, "Bandcamp"), "$825.35"); // 850 - 24.65
350 + assert_eq!(net_of(&r, "Ko-fi Gold"), "$947.00"); // 959 - 12
351 + assert_eq!(net_of(&r, "Lemon Squeezy"), "$930.00"); // 950 - 20
352 + }
353 +
354 + /// Rows are sorted by net kept, descending, and the MNW row is present.
355 + #[test]
356 + fn rows_sorted_descending() {
357 + let r = loaded().compute(1000.0, 16.0);
358 + assert_eq!(r.rows.len(), 10);
359 + assert!(r.rows.iter().any(|row| row.is_mnw));
360 + // At $1,000 Ko-fi Gold ($947) beats MNW Basic ($943), so MNW is not
361 + // first and a breakeven note appears.
362 + assert!(r.breakeven.is_some());
363 + assert_eq!(r.rows[0].name, "Ko-fi Gold");
364 + }
365 +
366 + /// Bandcamp's rate drops from 15% to 10% above the $5k threshold.
367 + #[test]
368 + fn bandcamp_tier_switch_at_threshold() {
369 + let p = loaded();
370 + // At $4,000 (below): 15%. ap = 3400; net = 3400 - 3400*0.029 = 3301.40
371 + assert_eq!(net_of(&p.compute(4000.0, 16.0), "Bandcamp"), "$3,301.40");
372 + // At $6,000 (above): 10%. ap = 5400; net = 5400 - 5400*0.029 = 5243.40
373 + assert_eq!(net_of(&p.compute(6000.0, 16.0), "Bandcamp"), "$5,243.40");
374 + }
375 +
376 + /// Zero revenue renders placeholders, not computed money.
377 + #[test]
378 + fn zero_revenue_is_placeholder() {
379 + let r = loaded().compute(0.0, 16.0);
380 + assert_eq!(r.mnw_keep, "$0.00");
381 + assert!(r.rows.iter().all(|row| row.keep == "--"));
382 + assert!(r.breakeven.is_none());
383 + }
384 +
385 + #[test]
386 + fn money_formatting_matches_js() {
387 + assert_eq!(fmt_money(943.0), "$943.00");
388 + assert_eq!(fmt_money(1234.5), "$1,234.50");
389 + assert_eq!(fmt_money(-12.34), "$-12.34");
390 + assert_eq!(fmt_whole(1000.0), "$1,000");
391 + assert_eq!(fmt_whole(5243.4), "$5,243");
392 + }
393 + }
@@ -431,10 +431,47 @@ pub(super) async fn pricing_page(
431 431 State(state): State<AppState>,
432 432 session: Session,
433 433 ) -> impl IntoResponse {
434 + let comparison = state
435 + .pricing_comparison
436 + .compute(PRICING_DEFAULT_REVENUE, state.tier_prices.basic_std as f64);
434 437 PricingTemplate {
435 438 csrf_token: get_csrf_token(&session).await,
436 439 tier_prices: state.tier_prices.clone(),
437 440 cost_allocation: state.cost_allocation.clone(),
441 + comparison,
442 + }
443 + }
444 +
445 + /// Default monthly revenue the calculator opens on — matches the `value` on the
446 + /// revenue input in `pricing.html`.
447 + const PRICING_DEFAULT_REVENUE: f64 = 1000.0;
448 +
449 + /// Query for the pricing-comparison HTMX recompute. Both fields arrive as
450 + /// strings (the revenue input can be cleared to empty) and are parsed
451 + /// leniently so a blank or garbage value recomputes at zero rather than 400ing.
452 + #[derive(Deserialize)]
453 + pub(super) struct PricingCompareQuery {
454 + revenue: Option<String>,
455 + tier: Option<String>,
456 + }
457 +
458 + /// Recompute the pricing comparison server-side and return the table partial.
459 + /// Pure computation, no auth, no state change — a GET so no CSRF is needed.
460 + #[tracing::instrument(skip_all, name = "landing::pricing_compare")]
461 + pub(super) async fn pricing_compare(
462 + State(state): State<AppState>,
463 + Query(q): Query<PricingCompareQuery>,
464 + ) -> impl IntoResponse {
465 + let parse = |s: Option<String>| {
466 + s.and_then(|v| v.trim().parse::<f64>().ok())
467 + .filter(|v| v.is_finite())
468 + };
469 + let revenue = parse(q.revenue).unwrap_or(0.0).clamp(0.0, 999_999.0);
470 + let tier = parse(q.tier)
471 + .filter(|v| *v >= 0.0)
472 + .unwrap_or(state.tier_prices.basic_std as f64);
473 + PricingComparisonPartial {
474 + comparison: state.pricing_comparison.compute(revenue, tier),
438 475 }
439 476 }
440 477
@@ -96,6 +96,7 @@ pub fn public_routes() -> CsrfRouter<AppState> {
96 96 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
97 97 .route_get("/buy/{item_id}", get(content::buy_page))
98 98 .route_get("/pricing", get(landing::pricing_page))
99 + .route_get("/pricing/compare", get(landing::pricing_compare))
99 100 .route_get("/checkout/complete", get(landing::checkout_complete))
100 101 .route_get("/use-cases", get(landing::use_cases_page))
101 102 .route_get("/team", get(landing::team_page))
@@ -143,6 +143,7 @@ impl_into_response!(
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
@@ -829,6 +829,17 @@ pub struct PricingTemplate {
829 829 pub csrf_token: CsrfTokenOption,
830 830 pub tier_prices: crate::tier_prices::TierPrices,
831 831 pub cost_allocation: crate::tier_prices::CostAllocation,
832 + /// Server-computed initial comparison (default revenue + Basic tier). The
833 + /// page re-fetches this partial via HTMX as the inputs change.
834 + pub comparison: crate::pricing_comparison::ComparisonResult,
835 + }
836 +
837 + /// HTMX partial: the recomputed pricing comparison (MNW summary + competitor
838 + /// table + breakeven note), swapped into `/pricing` on input change.
839 + #[derive(Template)]
840 + #[template(path = "partials/pricing_comparison.html")]
841 + pub struct PricingComparisonPartial {
842 + pub comparison: crate::pricing_comparison::ComparisonResult,
832 843 }
833 844
834 845 /// Platform economics + runway disclosure page. Renders at `/economics`
@@ -1,256 +0,0 @@
1 - (function() {
2 - 'use strict';
3 -
4 - var cfg = document.getElementById('page-pricing-cfg');
5 - var basicStd = cfg ? parseInt(cfg.dataset.basicStd, 10) : 0;
6 -
7 - var STRIPE_PERCENT = 0.029;
8 - var STRIPE_FIXED = 0.30;
9 -
10 - // Average transactions per month at a given revenue level.
11 - // Assumes ~$25 average transaction for simplicity.
12 - function estimateTransactions(revenue) {
13 - if (revenue <= 0) return 0;
14 - return Math.max(1, Math.round(revenue / 25));
15 - }
16 -
17 - function stripeNet(revenue) {
18 - if (revenue <= 0) return 0;
19 - var txns = estimateTransactions(revenue);
20 - return revenue - (revenue * STRIPE_PERCENT) - (txns * STRIPE_FIXED);
21 - }
22 -
23 - var competitors = [
24 - {
25 - name: 'Patreon',
26 - url: 'https://www.patreon.com/pricing',
27 - fee: '10% + ~3% + $0.30',
28 - calc: function(rev) {
29 - if (rev <= 0) return 0;
30 - var txns = estimateTransactions(rev);
31 - var afterPlatform = rev * 0.90;
32 - return afterPlatform - (afterPlatform * STRIPE_PERCENT) - (txns * STRIPE_FIXED);
33 - }
34 - },
35 - {
36 - name: 'Gumroad',
37 - url: 'https://gumroad.com/pricing',
38 - fee: '10% + $0.50/tx + ~3%',
39 - calc: function(rev) {
40 - if (rev <= 0) return 0;
41 - var txns = estimateTransactions(rev);
42 - var afterPlatform = rev * 0.90 - (txns * 0.50);
43 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
44 - }
45 - },
46 - {
47 - name: 'Bandcamp',
48 - url: 'https://bandcamp.com/pricing',
49 - fee: '15% (10% after $5k) + ~3%',
50 - calc: function(rev) {
51 - if (rev <= 0) return 0;
52 - var rate = rev > 5000 ? 0.10 : 0.15;
53 - var afterPlatform = rev * (1 - rate);
54 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
55 - }
56 - },
57 - {
58 - name: 'Ko-fi Free',
59 - url: 'https://ko-fi.com/pricing',
60 - fee: '0% tips, 5% shop/memberships + ~3%',
61 - calc: function(rev) {
62 - if (rev <= 0) return 0;
63 - var afterPlatform = rev * 0.95;
64 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
65 - }
66 - },
67 - {
68 - name: 'Ko-fi Gold',
69 - url: 'https://ko-fi.com/pricing',
70 - fee: '$12/mo + 0% + ~3%',
71 - calc: function(rev) {
72 - if (rev <= 0) return -12;
73 - return stripeNet(rev) - 12;
74 - }
75 - },
76 - {
77 - name: 'Itch.io',
78 - url: 'https://itch.io/docs/creators/faq',
79 - fee: '10% default + ~3%',
80 - calc: function(rev) {
81 - if (rev <= 0) return 0;
82 - var afterPlatform = rev * 0.90;
83 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
84 - }
85 - },
86 - {
87 - name: 'Lemon Squeezy',
88 - url: 'https://www.lemonsqueezy.com/pricing',
89 - fee: '5% + $0.50/tx (incl. processing)',
90 - calc: function(rev) {
91 - if (rev <= 0) return 0;
92 - var txns = estimateTransactions(rev);
93 - return rev * 0.95 - (txns * 0.50);
94 - }
95 - },
96 - {
97 - name: 'Buy Me a Coffee',
98 - url: 'https://www.buymeacoffee.com/about',
99 - fee: '5% + ~3%',
100 - calc: function(rev) {
101 - if (rev <= 0) return 0;
102 - var afterPlatform = rev * 0.95;
103 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
104 - }
105 - },
106 - {
107 - name: 'Substack',
108 - url: 'https://substack.com/going-paid',
109 - fee: '10% on paid subs',
110 - calc: function(rev) {
111 - if (rev <= 0) return 0;
112 - var afterPlatform = rev * 0.90;
113 - return afterPlatform - (afterPlatform * STRIPE_PERCENT);
114 - }
115 - }
116 - ];
117 -
118 - function fmt(n) {
119 - return '$' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
120 - }
121 -
122 - function fmtWhole(n) {
123 - return '$' + Math.round(n).toLocaleString();
124 - }
125 -
126 - function update() {
127 - var revenueInput = document.getElementById('revenue');
128 - var revenue = parseFloat(revenueInput.value) || 0;
129 -
130 - var tierRadios = document.querySelectorAll('input[name="tier"]');
131 - // Default falls back to the Basic-tier radio value; templates render the canonical price.
132 - var tierCost = basicStd;
133 - for (var i = 0; i < tierRadios.length; i++) {
134 - if (tierRadios[i].checked) {
135 - tierCost = parseInt(tierRadios[i].value, 10);
136 - break;
137 - }
138 - }
139 -
140 - // MNW net: revenue minus processing fees minus monthly membership
141 - var mnwNet = stripeNet(revenue) - tierCost;
142 - if (revenue <= 0) mnwNet = -tierCost;
143 -
144 - document.getElementById('mnw-keep').textContent = fmt(Math.max(0, mnwNet));
145 - document.getElementById('mnw-detail').textContent =
146 - 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo membership';
147 -
148 - // Build comparison rows sorted by net (descending)
149 - var rows = [];
150 - rows.push({
151 - name: 'Makenot.work',
152 - fee: fmt(tierCost) + '/mo flat + ~3% processing',
153 - net: mnwNet,
154 - isMnw: true
155 - });
156 -
157 - for (var j = 0; j < competitors.length; j++) {
158 - var c = competitors[j];
159 - rows.push({
160 - name: c.name,
161 - url: c.url,
162 - fee: c.fee,
163 - net: c.calc(revenue),
164 - isMnw: false
165 - });
166 - }
167 -
168 - rows.sort(function(a, b) { return b.net - a.net; });
169 -
170 - var tbody = document.getElementById('comparison-body');
171 - tbody.innerHTML = '';
172 -
173 - var breakevenMessages = [];
174 -
175 - for (var k = 0; k < rows.length; k++) {
176 - var row = rows[k];
177 - var tr = document.createElement('tr');
178 - if (row.isMnw) tr.className = 'highlight';
179 -
180 - var tdName = document.createElement('td');
181 - if (row.isMnw) {
182 - tdName.textContent = row.name;
183 - tdName.style.fontWeight = '600';
184 - } else {
185 - var link = document.createElement('a');
186 - link.href = row.url;
187 - link.textContent = row.name;
188 - link.target = '_blank';
189 - link.rel = 'noopener';
190 - tdName.appendChild(link);
191 - }
192 - tr.appendChild(tdName);
193 -
194 - var tdFee = document.createElement('td');
195 - tdFee.textContent = row.fee;
196 - tr.appendChild(tdFee);
197 -
198 - var tdKeep = document.createElement('td');
199 - tdKeep.textContent = revenue > 0 ? fmt(Math.max(0, row.net)) : '--';
200 - tr.appendChild(tdKeep);
201 -
202 - var tdDiff = document.createElement('td');
203 - if (row.isMnw) {
204 - tdDiff.textContent = '--';
205 - } else if (revenue > 0) {
206 - var diff = mnwNet - row.net;
207 - if (diff > 0.005) {
208 - tdDiff.textContent = '+' + fmt(diff);
209 - tdDiff.className = 'savings-negative';
210 - } else if (diff < -0.005) {
211 - tdDiff.textContent = fmt(diff);
212 - tdDiff.className = 'savings-positive';
213 - breakevenMessages.push(row.name);
214 - } else {
215 - tdDiff.textContent = '$0.00';
216 - }
217 - } else {
218 - tdDiff.textContent = '--';
219 - }
220 - tr.appendChild(tdDiff);
221 -
222 - tbody.appendChild(tr);
223 - }
224 -
225 - // Breakeven note
226 - var breakevenSection = document.getElementById('breakeven-section');
227 - var breakevenNote = document.getElementById('breakeven-note');
228 - if (breakevenMessages.length > 0 && revenue > 0) {
229 - breakevenSection.classList.remove('hidden');
230 - breakevenNote.textContent =
231 - 'At ' + fmtWhole(revenue) + '/mo, some platforms with percentage-only fees cost less than MNW\'s flat rate. '
232 - + 'MNW becomes cheaper as your revenue grows.';
233 - } else {
234 - breakevenSection.classList.add('hidden');
235 - }
236 - }
237 -
238 - // Tier selector visual state. Listen on the radio inputs so keyboard
239 - // navigation (arrow keys within the radiogroup) updates the styling.
240 - var tierInputs = document.querySelectorAll('.tier-option input[type="radio"]');
241 - var tierLabels = document.querySelectorAll('.tier-option');
242 - for (var i = 0; i < tierInputs.length; i++) {
243 - tierInputs[i].addEventListener('change', function() {
244 - for (var j = 0; j < tierLabels.length; j++) {
245 - tierLabels[j].classList.remove('is-selected');
246 - }
247 - this.closest('.tier-option').classList.add('is-selected');
248 - update();
249 - });
250 - }
251 -
252 - document.getElementById('revenue').addEventListener('input', update);
253 -
254 - // Initial calculation
255 - update();
256 - })();
@@ -9,74 +9,61 @@
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>
18 - </div>
19 - </div>
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">
20 23
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>
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>
49 31 </div>
50 - </div>
51 32
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>
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>
57 62 </div>
58 - </div>
59 63
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>
64 + <div id="comparison-panel">
65 + {% include "partials/pricing_comparison.html" %}
66 + </div>
80 67 </div>
81 68
82 69 <div class="tier-section cost-allocation" id="cost-allocation">
@@ -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 %}
@@ -0,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{% if row.is_mnw %} style="font-weight:600"{% endif %}>
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 %}