Skip to main content

max / makenotwork

server: cost-allocation widget round-trips through assumptions.toml Per-tier monthly fee allocation moves from hardcoded numbers in pricing.html into [cost_allocation.*] in docs/business/assumptions.toml. New CostAllocation struct loads + pre-formats the segments (amount, flex cents, tooltip, aria-label) so the template stays a simple loop. CSS classes (cost-bar-seg-*) unchanged. Model: - Stripe = 2.9% × price + $0.30 (actual fee formula) - Storage = at-rest + egress + scan + backups + cushion, ~5–15× the bare Hetzner math in [creator_marginal] for spike + price-hike margin - Support flat $5/creator (per-creator floor, doesn't scale with tier) - Engineering flat $6/creator (engineering hours don't scale with tier) - Reserves 12.5% of revenue (scales with tier; matches Basic's $2) - Earn-back is the residual — bigger on higher tiers; the truth is that higher-tier creators subsidize platform development Founder-rate note moves to the top of the widget instead of hidden in a trailing disclaimer: "Founder pays 50% of standard; per-creator fixed costs stay the same; the discount comes out of Earn-back and Reserves." No toggle UI — single canonical view, sketch of founder math in copy. Two new tier_prices tests pin the sum-equals-price invariant and the aria-label structure; 1,663 lib tests passing.
Author: Max Johnson <me@maxj.phd> · 2026-06-04 04:10 UTC
Commit: 4347b1ddb05f4495ad3e72b1591edb639146b4cc
Parent: 0416215
7 files changed, +258 insertions, -50 deletions
@@ -237,3 +237,59 @@ chargeback_rate_tier_subs = 0.001 # A12: 0.1% for recurring (lower than one-
237 237 # - rho_incident ≤ rho_annual (single decision ≤ annual budget)
238 238 # - All `tiers.founding[k]` ≤ `tiers.standard[k]`
239 239 # - cap_count > 0, cap_months > 0
240 +
241 +
242 + # ─── Per-tier monthly fee allocation (Mode 1 widget on /pricing) ─────────
243 + #
244 + # Each row sums to the standard tier price. The widget renders standard-rate
245 + # numbers only; founder-rate creators pay 50% of standard with the subsidy
246 + # absorbed by reserves + earnback, so the allocation shape is the same but
247 + # the visible total scales down — a one-line note on the pricing page covers
248 + # that case.
249 + #
250 + # Model (per `site-docs/public/about/economics.md`):
251 + # - Stripe: 2.9% × price + $0.30 (Stripe's actual fee formula)
252 + # - Storage: at-rest + egress + virus-scan + backups + provider-price
253 + # cushion; ~5–15× the bare Hetzner at-rest math from
254 + # [creator_marginal] to absorb spikes
255 + # - Support: flat $5.00/creator — per-creator floor, doesn't scale
256 + # with tier (a Basic text creator and a $60 video creator
257 + # each get the same human availability)
258 + # - Engineering: flat $6.00/creator — engineering hours don't scale with
259 + # what tier someone bought; the same platform serves all
260 + # - Reserves: 12.5% of revenue (risk float for refunds/chargebacks/runway)
261 + # - Earn-back: residual; the surplus from higher tiers funds platform
262 + # development that benefits everyone
263 + #
264 + # Numbers below are pinned by `tier_prices::tests::cost_allocation_*`.
265 + [cost_allocation.basic]
266 + stripe = 0.76
267 + storage = 0.40
268 + support = 5.00
269 + engineering = 6.00
270 + reserves = 2.00
271 + earnback = 1.84
272 +
273 + [cost_allocation.small_files]
274 + stripe = 1.00
275 + storage = 1.00
276 + support = 5.00
277 + engineering = 6.00
278 + reserves = 3.00
279 + earnback = 8.00
280 +
281 + [cost_allocation.big_files]
282 + stripe = 1.34
283 + storage = 3.00
284 + support = 5.00
285 + engineering = 6.00
286 + reserves = 4.50
287 + earnback = 16.16
288 +
289 + [cost_allocation.everything]
290 + stripe = 2.04
291 + storage = 6.00
292 + support = 5.00
293 + engineering = 6.00
294 + reserves = 7.50
295 + earnback = 33.46
@@ -75,6 +75,7 @@ pub struct AppState {
75 75 pub email: EmailClient,
76 76 pub docs: Arc<DocLoader>,
77 77 pub tier_prices: tier_prices::TierPrices,
78 + pub cost_allocation: tier_prices::CostAllocation,
78 79 pub scanner: Option<Arc<ScanPipeline>>,
79 80 pub webauthn: Arc<Webauthn>,
80 81 pub syntax: Option<Arc<git::SyntaxHighlighter>>,
@@ -318,6 +318,10 @@ async fn main() {
318 318 email,
319 319 docs,
320 320 tier_prices: makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions),
321 + cost_allocation: {
322 + let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
323 + makenotwork::tier_prices::CostAllocation::from_assumptions(&assumptions, &tp)
324 + },
321 325 scanner,
322 326 webauthn,
323 327 syntax,
@@ -356,6 +356,7 @@ pub(super) async fn pricing_page(
356 356 PricingTemplate {
357 357 csrf_token: get_csrf_token(&session).await,
358 358 tier_prices: state.tier_prices.clone(),
359 + cost_allocation: state.cost_allocation.clone(),
359 360 }
360 361 }
361 362
@@ -768,6 +768,7 @@ pub struct DocIndexTemplate {
768 768 pub struct PricingTemplate {
769 769 pub csrf_token: CsrfTokenOption,
770 770 pub tier_prices: crate::tier_prices::TierPrices,
771 + pub cost_allocation: crate::tier_prices::CostAllocation,
771 772 }
772 773
773 774 /// Use cases page showcasing creator types.
@@ -138,6 +138,131 @@ impl TierPrices {
138 138 }
139 139 }
140 140
141 + /// One segment of the per-tier monthly fee bar. Sized in cents so the
142 + /// template can drop it straight into `flex: <cents>` for a stacked
143 + /// horizontal bar that scales proportionally without per-segment math.
144 + #[derive(Clone, Debug)]
145 + pub struct CostSegment {
146 + /// CSS modifier and toml key — `"stripe"`, `"storage"`, `"support"`,
147 + /// `"engineering"`, `"reserves"`, `"earnback"`. Drives `.cost-bar-seg-*`.
148 + pub kind: &'static str,
149 + /// Human label e.g. `"Stripe processing"`.
150 + pub label: &'static str,
151 + /// Pre-formatted dollar string e.g. `"$0.76"` for the segment + tooltip.
152 + pub amount: String,
153 + /// Amount in cents — the flex weight for the stacked bar.
154 + pub cents: i32,
155 + /// Pre-formatted hover text (rendered into `title="…"`). Kept on the
156 + /// Rust side so copy reviews land here and not in the template.
157 + pub tooltip: String,
158 + }
159 +
160 + /// One row of the cost-allocation widget — a single tier and its six
161 + /// fee segments. The template iterates `rows` and per row iterates
162 + /// `segments`, so the markup is fully uniform.
163 + #[derive(Clone, Debug)]
164 + pub struct CostAllocationRow {
165 + pub tier_key: &'static str,
166 + pub tier_label: &'static str,
167 + /// Monthly price in dollars (whole-dollar tiers today).
168 + pub tier_price: i32,
169 + pub segments: Vec<CostSegment>,
170 + /// Pre-built `aria-label` for the bar — names every segment + value
171 + /// so screen readers get the breakdown.
172 + pub aria_label: String,
173 + }
174 +
175 + /// All four tier rows, in canonical Basic → Everything order.
176 + #[derive(Clone, Debug, Default)]
177 + pub struct CostAllocation {
178 + pub rows: Vec<CostAllocationRow>,
179 + }
180 +
181 + impl CostAllocation {
182 + pub fn from_assumptions(a: &Assumptions, tp: &TierPrices) -> Self {
183 + let rows = vec![
184 + row(a, "basic", "Basic", tp.basic_std),
185 + row(a, "small_files", "Small Files", tp.small_files_std),
186 + row(a, "big_files", "Big Files", tp.big_files_std),
187 + row(a, "everything", "Everything", tp.everything_std),
188 + ];
189 + Self { rows }
190 + }
191 + }
192 +
193 + fn row(a: &Assumptions, key: &'static str, label: &'static str, price: i32) -> CostAllocationRow {
194 + let segments = vec![
195 + seg(a, key, "stripe", "Stripe processing",
196 + "Stripe processing: {amount}. Stripe's fee on your monthly subscription."),
197 + seg(a, key, "storage", "Storage",
198 + "Storage: {amount}. At-rest storage, bandwidth, virus scanning, and \
199 + backups at your tier's typical fill — sized with headroom for spikes \
200 + and cloud-provider price changes."),
201 + seg(a, key, "support", "Human support time",
202 + "Human support time: {amount}. Identity recovery, billing disputes, \
203 + moderation, abuse, and legal — the work that can't be automated. \
204 + Per-creator floor; the same at every tier."),
205 + seg(a, key, "engineering", "Product engineering",
206 + "Product engineering: {amount}. Bug fixes and ongoing product work. \
207 + Per-creator floor; the same at every tier."),
208 + seg(a, key, "reserves", "Reserves",
209 + "Reserves: {amount}. Held against a bad month — refunds, chargebacks, \
210 + unexpected costs — so a single incident doesn't force a price change \
211 + or shutdown."),
212 + seg(a, key, "earnback", "Earn-back surplus",
213 + "Earn-back surplus: {amount}. The residual after costs. Funds platform \
214 + development that benefits every tier — bug fixes, new features, and \
215 + lower future prices as the cost base amortizes across more creators."),
216 + ];
217 + let aria_label = build_aria_label(label, price, &segments);
218 + CostAllocationRow {
219 + tier_key: key,
220 + tier_label: label,
221 + tier_price: price,
222 + segments,
223 + aria_label,
224 + }
225 + }
226 +
227 + fn seg(
228 + a: &Assumptions,
229 + tier_key: &str,
230 + seg_kind: &'static str,
231 + seg_label: &'static str,
232 + tooltip_template: &str,
233 + ) -> CostSegment {
234 + let dollars = float_at(a, &format!("cost_allocation.{tier_key}.{seg_kind}"));
235 + let cents = (dollars * 100.0).round() as i32;
236 + let amount = format!("${dollars:.2}");
237 + let tooltip = tooltip_template.replace("{amount}", &amount);
238 + CostSegment {
239 + kind: seg_kind,
240 + label: seg_label,
241 + amount,
242 + cents,
243 + tooltip,
244 + }
245 + }
246 +
247 + fn build_aria_label(tier_label: &str, price: i32, segments: &[CostSegment]) -> String {
248 + let mut s = format!("{tier_label} tier ${price}/mo allocation: ");
249 + for (i, seg) in segments.iter().enumerate() {
250 + if i > 0 { s.push_str(", "); }
251 + s.push_str(seg.label);
252 + s.push(' ');
253 + s.push_str(&seg.amount);
254 + }
255 + s
256 + }
257 +
258 + fn float_at(a: &Assumptions, key: &str) -> f64 {
259 + match a.get(key) {
260 + Some(LookupValue::Float(x)) => *x,
261 + Some(LookupValue::Int(n)) => *n as f64,
262 + other => panic!("expected number at {key}, got {other:?}"),
263 + }
264 + }
265 +
141 266 fn int_at(a: &Assumptions, key: &str) -> i32 {
142 267 match a.get(key) {
143 268 Some(LookupValue::Int(n)) => i32::try_from(*n)
@@ -164,6 +289,64 @@ mod tests {
164 289 /// these or flips its type, the panic in `from_assumptions` will fire at
165 290 /// startup; this test catches it at PR time instead.
166 291 #[test]
292 + fn cost_allocation_from_canonical_assumptions_sums_to_tier_price() {
293 + // Each row's six segments must sum to the standard tier price.
294 + // A future toml edit that breaks that invariant — say a typo in
295 + // `cost_allocation.big_files.storage` — would render a stacked
296 + // bar whose visible total disagrees with the headline price.
297 + // Catch the divergence at PR time.
298 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
299 + let tp = TierPrices::from_assumptions(&a);
300 + let alloc = CostAllocation::from_assumptions(&a, &tp);
301 + assert_eq!(alloc.rows.len(), 4);
302 +
303 + let prices = [tp.basic_std, tp.small_files_std, tp.big_files_std, tp.everything_std];
304 + for (row, &price) in alloc.rows.iter().zip(prices.iter()) {
305 + assert_eq!(row.tier_price, price);
306 + let sum_cents: i32 = row.segments.iter().map(|s| s.cents).sum();
307 + assert_eq!(
308 + sum_cents,
309 + price * 100,
310 + "{tier} segments sum to {sum_cents}¢ but tier price is ${price} = {expected}¢",
311 + tier = row.tier_label,
312 + expected = price * 100,
313 + );
314 + // Six canonical segments in canonical order.
315 + let kinds: Vec<&str> = row.segments.iter().map(|s| s.kind).collect();
316 + assert_eq!(
317 + kinds,
318 + vec!["stripe", "storage", "support", "engineering", "reserves", "earnback"],
319 + );
320 + }
321 +
322 + // Spot-check the per-segment values match what we agreed in the
323 + // economics doc. A drift here means the toml was edited without
324 + // a corresponding rationale update.
325 + let basic = &alloc.rows[0];
326 + assert_eq!(basic.segments[0].amount, "$0.76"); // stripe
327 + assert_eq!(basic.segments[5].amount, "$1.84"); // earnback
328 + let everything = &alloc.rows[3];
329 + assert_eq!(everything.segments[2].amount, "$5.00"); // support flat
330 + assert_eq!(everything.segments[3].amount, "$6.00"); // engineering flat
331 + assert_eq!(everything.segments[4].amount, "$7.50"); // reserves 12.5% of $60
332 + }
333 +
334 + #[test]
335 + fn cost_allocation_aria_label_names_every_segment() {
336 + // Screen readers get the full breakdown via aria-label since the
337 + // colored bar carries no native semantics. Pin the format.
338 + let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
339 + let tp = TierPrices::from_assumptions(&a);
340 + let alloc = CostAllocation::from_assumptions(&a, &tp);
341 + let aria = &alloc.rows[0].aria_label;
342 + assert!(aria.starts_with("Basic tier $16/mo allocation: "));
343 + for label in ["Stripe processing", "Storage", "Human support time",
344 + "Product engineering", "Reserves", "Earn-back surplus"] {
345 + assert!(aria.contains(label), "aria-label missing {label}: {aria}");
346 + }
347 + }
348 +
349 + #[test]
167 350 fn from_canonical_assumptions_populates_every_field() {
168 351 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
169 352 let p = TierPrices::from_assumptions(&a);
@@ -82,58 +82,22 @@
82 82 <div class="tier-section cost-allocation" id="cost-allocation">
83 83 <h2 class="section-label">Where your tier fee goes</h2>
84 84 <p class="cost-allocation-intro">Per typical creator, monthly. These are platform-wide budget allocations, not per-account accounting. Hover a segment for details.</p>
85 + <p class="cost-allocation-founder-note">Founder-rate creators pay exactly 50% of the standard tier. Per-creator fixed costs (Support, Engineering, Storage, Stripe) stay the same; the discount comes out of Earn-back and Reserves. The shape is unchanged, the totals halve.</p>
85 86
86 - {# Basic — $16 = 0.76 + 0.40 + 5.00 + 6.00 + 2.00 + 1.84. Sub-amounts hardcoded; see launchplan §4.5.1 for the planned [cost_allocation] toml section. #}
87 + {# Every dollar here round-trips through `cost_allocation.*` in
88 + `docs/business/assumptions.toml`. Tests in `tier_prices::tests`
89 + pin the per-segment values; tooltip copy lives in the Rust
90 + builder. #}
91 + {% for row in cost_allocation.rows %}
87 92 <div class="cost-row">
88 - <div class="cost-row-label"><strong>Basic</strong>${{ tier_prices.basic_std }}/mo</div>
89 - <div class="cost-bar" role="img" aria-label="Basic tier ${{ tier_prices.basic_std }}/mo allocation: Stripe $0.76, Storage $0.40, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $1.84">
90 - <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 76" title="Stripe processing: $0.76. Stripe's fee on your monthly subscription.">$0.76</div>
91 - <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 40" title="Storage: $0.40. Object storage at your tier's typical fill, on Hetzner.">$0.40</div>
92 - <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00. Identity recovery, billing disputes, moderation, abuse, and legal — the work that can't be automated.">$5.00</div>
93 - <div class="cost-bar-seg cost-bar-seg-engineering" style="flex: 600" title="Product engineering: $6.00. Bug fixes and ongoing product work. We treat fixing bugs as part of building the product, not as an ongoing support drag.">$6.00</div>
94 - <div class="cost-bar-seg cost-bar-seg-reserves" style="flex: 200" title="Reserves: $2.00. Held against a bad month so a single incident doesn't force a price change or a shutdown.">$2.00</div>
95 - <div class="cost-bar-seg cost-bar-seg-earnback" style="flex: 184" title="Earn-back surplus: $1.84. Earmarked to return to creators as earn-back credit (committed by 2027-01-01).">$1.84</div>
96 - </div>
97 - </div>
98 -
99 - {# Small Files — $24 = 1.00 + 0.60 + 5.00 + 6.00 + 2.00 + 9.40 #}
100 - <div class="cost-row">
101 - <div class="cost-row-label"><strong>Small Files</strong>${{ tier_prices.small_files_std }}/mo</div>
102 - <div class="cost-bar" role="img" aria-label="Small Files tier ${{ tier_prices.small_files_std }}/mo allocation: Stripe $1.00, Storage $0.60, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $9.40">
103 - <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 100" title="Stripe processing: $1.00.">$1.00</div>
104 - <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 60" title="Storage: $0.60. Object storage at your tier's typical fill.">$0.60</div>
105 - <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div>
106 - <div class="cost-bar-seg cost-bar-seg-engineering" style="flex: 600" title="Product engineering: $6.00.">$6.00</div>
107 - <div class="cost-bar-seg cost-bar-seg-reserves" style="flex: 200" title="Reserves: $2.00.">$2.00</div>
108 - <div class="cost-bar-seg cost-bar-seg-earnback" style="flex: 940" title="Earn-back surplus: $9.40.">$9.40</div>
109 - </div>
110 - </div>
111 -
112 - {# Big Files — $36 = 1.34 + 0.90 + 5.00 + 6.00 + 2.00 + 20.76 #}
113 - <div class="cost-row">
114 - <div class="cost-row-label"><strong>Big Files</strong>${{ tier_prices.big_files_std }}/mo</div>
115 - <div class="cost-bar" role="img" aria-label="Big Files tier ${{ tier_prices.big_files_std }}/mo allocation: Stripe $1.34, Storage $0.90, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $20.76">
116 - <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 134" title="Stripe processing: $1.34.">$1.34</div>
117 - <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 90" title="Storage: $0.90.">$0.90</div>
118 - <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div>
119 - <div class="cost-bar-seg cost-bar-seg-engineering" style="flex: 600" title="Product engineering: $6.00.">$6.00</div>
120 - <div class="cost-bar-seg cost-bar-seg-reserves" style="flex: 200" title="Reserves: $2.00.">$2.00</div>
121 - <div class="cost-bar-seg cost-bar-seg-earnback" style="flex: 2076" title="Earn-back surplus: $20.76.">$20.76</div>
122 - </div>
123 - </div>
124 -
125 - {# Everything — $60 = 2.04 + 1.50 + 5.00 + 6.00 + 2.00 + 43.46 #}
126 - <div class="cost-row">
127 - <div class="cost-row-label"><strong>Everything</strong>${{ tier_prices.everything_std }}/mo</div>
128 - <div class="cost-bar" role="img" aria-label="Everything tier ${{ tier_prices.everything_std }}/mo allocation: Stripe $2.04, Storage $1.50, Support $5.00, Engineering $6.00, Reserves $2.00, Earn-back $43.46">
129 - <div class="cost-bar-seg cost-bar-seg-stripe" style="flex: 204" title="Stripe processing: $2.04.">$2.04</div>
130 - <div class="cost-bar-seg cost-bar-seg-storage" style="flex: 150" title="Storage: $1.50.">$1.50</div>
131 - <div class="cost-bar-seg cost-bar-seg-support" style="flex: 500" title="Human support time: $5.00.">$5.00</div>
132 - <div class="cost-bar-seg cost-bar-seg-engineering" style="flex: 600" title="Product engineering: $6.00.">$6.00</div>
133 - <div class="cost-bar-seg cost-bar-seg-reserves" style="flex: 200" title="Reserves: $2.00.">$2.00</div>
134 - <div class="cost-bar-seg cost-bar-seg-earnback" style="flex: 4346" title="Earn-back surplus: $43.46.">$43.46</div>
93 + <div class="cost-row-label"><strong>{{ row.tier_label }}</strong>${{ row.tier_price }}/mo</div>
94 + <div class="cost-bar" role="img" aria-label="{{ row.aria_label }}">
95 + {% for seg in row.segments %}
96 + <div class="cost-bar-seg cost-bar-seg-{{ seg.kind }}" style="flex: {{ seg.cents }}" title="{{ seg.tooltip }}">{{ seg.amount }}</div>
97 + {% endfor %}
135 98 </div>
136 99 </div>
100 + {% endfor %}
137 101
138 102 <div class="cost-legend" aria-hidden="true">
139 103 <span class="cost-legend-item"><span class="cost-legend-swatch cost-bar-seg-stripe"></span>Stripe processing</span>
@@ -143,8 +107,6 @@
143 107 <span class="cost-legend-item"><span class="cost-legend-swatch cost-bar-seg-reserves"></span>Reserves</span>
144 108 <span class="cost-legend-item"><span class="cost-legend-swatch cost-bar-seg-earnback"></span>Earn-back surplus</span>
145 109 </div>
146 -
147 - <p class="pricing-disclaimer">Founder-rate creators pay exactly 50% of the standard tier; that subsidy is absorbed by the standard cohort and platform reserves, so the breakdown above applies to standard rates.</p>
148 110 </div>
149 111
150 112 <div class="landing-cta">