Skip to main content

max / makenotwork

Regenerate openapi.json for v0.11.6 The document embeds the crate version, so every release bump makes the vendored copy stale and reds the cargo_test gate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 18:52 UTC
Signed with PGP, not checked
Commit: 658444980363645defa9797b87913e1ebbed06b9
Parent: 71221bc
3 files changed, +1 insertion, -457 deletions
@@ -6,7 +6,7 @@
6 6 "license": {
7 7 "name": "PolyForm Noncommercial 1.0.0"
8 8 },
9 - "version": "0.11.5"
9 + "version": "0.11.6"
10 10 },
11 11 "paths": {
12 12 "/api/v1/items/{item_id}/license.txt": {
@@ -1,403 +1,0 @@
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,53 +1,0 @@
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 %}