max / makenotwork
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 %} |