max / makenotwork
7 files changed,
+81 insertions,
-33 deletions
| @@ -2431,7 +2431,7 @@ dependencies = [ | |||
| 2431 | 2431 | ||
| 2432 | 2432 | [[package]] | |
| 2433 | 2433 | name = "docengine" | |
| 2434 | - | version = "0.3.4" | |
| 2434 | + | version = "0.3.5" | |
| 2435 | 2435 | dependencies = [ | |
| 2436 | 2436 | "ammonia", | |
| 2437 | 2437 | "pulldown-cmark", |
| @@ -24,10 +24,10 @@ Every tier is the complete platform: profile page at `/u/username`, project and | |||
| 24 | 24 | ||
| 25 | 25 | | Tier | Monthly | Annual (10% off) | File-size envelope | | |
| 26 | 26 | |---|---|---|---| | |
| 27 | - | | **Basic** | ${{ tiers.founding.basic }}/mo | $86/yr | Up to {{ tier_limits.basic_per_file }} per file, {{ tier_limits.basic_total }} total. Fits text, blogs, newsletters, posts. | | |
| 28 | - | | **Small Files** | ${{ tiers.founding.small_files }}/mo | $130/yr | Up to {{ tier_limits.small_files_per_file }} per file, {{ tier_limits.small_files_total }} total. Fits audio, plugins, binaries, sample packs. | | |
| 29 | - | | **Big Files** | ${{ tiers.founding.big_files }}/mo | $194/yr | Up to {{ tier_limits.big_files_per_file }} per file, {{ tier_limits.big_files_total }} total. Fits video, games, large software. | | |
| 30 | - | | **Everything** | ${{ tiers.founding.everything }}/mo | $324/yr | Big Files envelope plus first access to high-cost capabilities as they ship (streaming infrastructure, etc.). | | |
| 27 | + | | **Basic** | ${{ tiers.founding.basic }}/mo | ${{ derived.annual_founding_basic }}/yr | Up to {{ tier_limits.basic_per_file }} per file, {{ tier_limits.basic_total }} total. Fits text, blogs, newsletters, posts. | | |
| 28 | + | | **Small Files** | ${{ tiers.founding.small_files }}/mo | ${{ derived.annual_founding_small_files }}/yr | Up to {{ tier_limits.small_files_per_file }} per file, {{ tier_limits.small_files_total }} total. Fits audio, plugins, binaries, sample packs. | | |
| 29 | + | | **Big Files** | ${{ tiers.founding.big_files }}/mo | ${{ derived.annual_founding_big_files }}/yr | Up to {{ tier_limits.big_files_per_file }} per file, {{ tier_limits.big_files_total }} total. Fits video, games, large software. | | |
| 30 | + | | **Everything** | ${{ tiers.founding.everything }}/mo | ${{ derived.annual_founding_everything }}/yr | Big Files envelope plus first access to high-cost capabilities as they ship (streaming infrastructure, etc.). | | |
| 31 | 31 | ||
| 32 | 32 | Annual is 10% off the monthly total. Most of that is the Stripe per-transaction fee we don't pay when we charge once a year instead of twelve times. We pass it back rather than keep it. | |
| 33 | 33 | ||
| @@ -41,10 +41,10 @@ The standard rate applies to new signups after the founder window closes. The va | |||
| 41 | 41 | ||
| 42 | 42 | | Tier | Monthly | Annual (10% off) | | |
| 43 | 43 | |---|---|---| | |
| 44 | - | | **Basic** | ${{ tiers.standard.basic }}/mo | $173/yr | | |
| 45 | - | | **Small Files** | ${{ tiers.standard.small_files }}/mo | $259/yr | | |
| 46 | - | | **Big Files** | ${{ tiers.standard.big_files }}/mo | $389/yr | | |
| 47 | - | | **Everything** | ${{ tiers.standard.everything }}/mo | $648/yr | | |
| 44 | + | | **Basic** | ${{ tiers.standard.basic }}/mo | ${{ derived.annual_standard_basic }}/yr | | |
| 45 | + | | **Small Files** | ${{ tiers.standard.small_files }}/mo | ${{ derived.annual_standard_small_files }}/yr | | |
| 46 | + | | **Big Files** | ${{ tiers.standard.big_files }}/mo | ${{ derived.annual_standard_big_files }}/yr | | |
| 47 | + | | **Everything** | ${{ tiers.standard.everything }}/mo | ${{ derived.annual_standard_everything }}/yr | | |
| 48 | 48 | ||
| 49 | 49 | --- | |
| 50 | 50 |
| @@ -8,10 +8,10 @@ We are running a founder window. **Every creator who joins right now pays half t | |||
| 8 | 8 | ||
| 9 | 9 | | Tier | Founder monthly | Founder annual (10% off) | Eventual sticker | | |
| 10 | 10 | |------|-----------------|--------------------------|------------------| | |
| 11 | - | | **Basic** | **${{ tiers.founding.basic }}/mo** | $86/yr | ${{ tiers.standard.basic }}/mo | | |
| 12 | - | | **Small Files** | **${{ tiers.founding.small_files }}/mo** | $130/yr | ${{ tiers.standard.small_files }}/mo | | |
| 13 | - | | **Big Files** | **${{ tiers.founding.big_files }}/mo** | $194/yr | ${{ tiers.standard.big_files }}/mo | | |
| 14 | - | | **Everything** | **${{ tiers.founding.everything }}/mo** | $324/yr | ${{ tiers.standard.everything }}/mo | | |
| 11 | + | | **Basic** | **${{ tiers.founding.basic }}/mo** | ${{ derived.annual_founding_basic }}/yr | ${{ tiers.standard.basic }}/mo | | |
| 12 | + | | **Small Files** | **${{ tiers.founding.small_files }}/mo** | ${{ derived.annual_founding_small_files }}/yr | ${{ tiers.standard.small_files }}/mo | | |
| 13 | + | | **Big Files** | **${{ tiers.founding.big_files }}/mo** | ${{ derived.annual_founding_big_files }}/yr | ${{ tiers.standard.big_files }}/mo | | |
| 14 | + | | **Everything** | **${{ tiers.founding.everything }}/mo** | ${{ derived.annual_founding_everything }}/yr | ${{ tiers.standard.everything }}/mo | | |
| 15 | 15 | ||
| 16 | 16 | The window closes when we reach {{ cohort.cap_display }} creators or when we exit beta, whichever comes first. After that, new signups pay the sticker price. | |
| 17 | 17 |
| @@ -9,7 +9,11 @@ | |||
| 9 | 9 | ||
| 10 | 10 | use docengine::Assumptions; | |
| 11 | 11 | ||
| 12 | - | const ASSUMPTIONS_PATH: &str = "docs/internal/business/assumptions.toml"; | |
| 12 | + | // Canonical assumptions.toml moved to _private/ on 2026-05-20. Test runs from | |
| 13 | + | // the server crate's cwd (MNW/server/); one level up gets to MNW/, two gets | |
| 14 | + | // to ~/Code/. Production uses ASSUMPTIONS_PATH env override (see main.rs). | |
| 15 | + | const ASSUMPTIONS_PATH: &str = | |
| 16 | + | "../../_private/docs/mnw/server-internal/business/assumptions.toml"; | |
| 13 | 17 | const SITE_DOCS_PATH: &str = "site-docs/public"; | |
| 14 | 18 | ||
| 15 | 19 | #[test] |
| @@ -84,7 +84,7 @@ dependencies = [ | |||
| 84 | 84 | ||
| 85 | 85 | [[package]] | |
| 86 | 86 | name = "docengine" | |
| 87 | - | version = "0.3.4" | |
| 87 | + | version = "0.3.5" | |
| 88 | 88 | dependencies = [ | |
| 89 | 89 | "ammonia", | |
| 90 | 90 | "pulldown-cmark", |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "docengine" | |
| 3 | - | version = "0.3.4" | |
| 3 | + | version = "0.3.5" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | ||
| 6 | 6 | [features] |
| @@ -334,6 +334,7 @@ struct Typed { | |||
| 334 | 334 | reserve: TReserve, | |
| 335 | 335 | cohort: TCohort, | |
| 336 | 336 | creator_marginal: TCreatorMarginal, | |
| 337 | + | annual_discount: TAnnualDiscount, | |
| 337 | 338 | } | |
| 338 | 339 | ||
| 339 | 340 | #[derive(Debug, Deserialize)] | |
| @@ -399,6 +400,11 @@ struct TCohort { | |||
| 399 | 400 | } | |
| 400 | 401 | ||
| 401 | 402 | #[derive(Debug, Deserialize)] | |
| 403 | + | struct TAnnualDiscount { | |
| 404 | + | multiplier: f64, | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | #[derive(Debug, Deserialize)] | |
| 402 | 408 | struct TCreatorMarginal { | |
| 403 | 409 | storage_basic_gb: f64, | |
| 404 | 410 | storage_small_files_gb: f64, | |
| @@ -465,6 +471,19 @@ fn insert_derived(t: &Typed, out: &mut HashMap<String, LookupValue>) { | |||
| 465 | 471 | put("stripe_fee_big_std", stripe_fee(t.tiers.standard.big_files)); | |
| 466 | 472 | put("stripe_fee_ev_std", stripe_fee(t.tiers.standard.everything)); | |
| 467 | 473 | ||
| 474 | + | // Annual prices per tier (monthly × 12 × annual_discount.multiplier, rounded | |
| 475 | + | // to nearest dollar). Substituted into docs as `${{ derived.annual_*_* }}` | |
| 476 | + | // so a price change auto-propagates. | |
| 477 | + | let yr = |monthly: f64| (monthly * 12.0 * t.annual_discount.multiplier).round(); | |
| 478 | + | put("annual_founding_basic", yr(t.tiers.founding.basic)); | |
| 479 | + | put("annual_founding_small_files", yr(t.tiers.founding.small_files)); | |
| 480 | + | put("annual_founding_big_files", yr(t.tiers.founding.big_files)); | |
| 481 | + | put("annual_founding_everything", yr(t.tiers.founding.everything)); | |
| 482 | + | put("annual_standard_basic", yr(t.tiers.standard.basic)); | |
| 483 | + | put("annual_standard_small_files", yr(t.tiers.standard.small_files)); | |
| 484 | + | put("annual_standard_big_files", yr(t.tiers.standard.big_files)); | |
| 485 | + | put("annual_standard_everything", yr(t.tiers.standard.everything)); | |
| 486 | + | ||
| 468 | 487 | // ── Marginal cost per creator/month, broken down by component ── | |
| 469 | 488 | // | |
| 470 | 489 | // What MNW pays per active creator on top of fixed costs F: | |
| @@ -544,7 +563,15 @@ fn insert_derived(t: &Typed, out: &mut HashMap<String, LookupValue>) { | |||
| 544 | 563 | mod tests { | |
| 545 | 564 | use super::*; | |
| 546 | 565 | ||
| 547 | - | const FIXTURE: &str = include_str!("../../../server/docs/internal/business/assumptions.toml"); | |
| 566 | + | // Canonical assumptions.toml lives outside the MNW repo at | |
| 567 | + | // ~/Code/_private/docs/mnw/server-internal/business/assumptions.toml (moved | |
| 568 | + | // 2026-05-20). include_str! resolves relative to this source file. A clone | |
| 569 | + | // of the MNW repo without _private/ alongside will fail to build the | |
| 570 | + | // assumptions feature; production never hits this path because main.rs | |
| 571 | + | // uses runtime Assumptions::load with ASSUMPTIONS_PATH env override. | |
| 572 | + | const FIXTURE: &str = include_str!( | |
| 573 | + | "../../../../_private/docs/mnw/server-internal/business/assumptions.toml" | |
| 574 | + | ); | |
| 548 | 575 | ||
| 549 | 576 | fn loaded() -> Assumptions { | |
| 550 | 577 | Assumptions::parse(FIXTURE).expect("fixture parses") | |
| @@ -630,9 +657,9 @@ mod tests { | |||
| 630 | 657 | assert!((storage - 0.03666).abs() < 1e-4, "storage = {storage}"); | |
| 631 | 658 | ||
| 632 | 659 | // Stripe fee on creator subs at standard rates (weighted by tier mix): | |
| 633 | - | // 0.4·0.59 + 0.3·0.88 + 0.2·1.17 + 0.1·2.04 = 0.938 | |
| 660 | + | // 0.4·0.764 + 0.3·0.996 + 0.2·1.344 + 0.1·2.040 = 1.0772 | |
| 634 | 661 | let stripe = get_f("derived.marginal_stripe_standard"); | |
| 635 | - | assert!((stripe - 0.938).abs() < 1e-4, "stripe = {stripe}"); | |
| 662 | + | assert!((stripe - 1.0772).abs() < 1e-4, "stripe = {stripe}"); | |
| 636 | 663 | ||
| 637 | 664 | // Chargeback EV: 0.001 × $15 dispute = $0.015 | |
| 638 | 665 | let chargeback = get_f("derived.marginal_chargeback"); | |
| @@ -643,9 +670,30 @@ mod tests { | |||
| 643 | 670 | assert!((avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}"); | |
| 644 | 671 | ||
| 645 | 672 | // Founding Stripe fee is lower (lower tier prices, less % component): | |
| 646 | - | // 0.4·0.445 + 0.3·0.59 + 0.2·0.735 + 0.1·1.17 = 0.619 | |
| 673 | + | // 0.4·0.532 + 0.3·0.648 + 0.2·0.822 + 0.1·1.170 = 0.6886 | |
| 647 | 674 | let stripe_f = get_f("derived.marginal_stripe_founding"); | |
| 648 | - | assert!((stripe_f - 0.619).abs() < 1e-4, "stripe_f = {stripe_f}"); | |
| 675 | + | assert!((stripe_f - 0.6886).abs() < 1e-4, "stripe_f = {stripe_f}"); | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | #[test] | |
| 679 | + | fn derived_annual_prices_match_published_values() { | |
| 680 | + | let a = loaded(); | |
| 681 | + | let get_f = |k: &str| match a.get(k).unwrap() { | |
| 682 | + | LookupValue::Float(x) => *x, | |
| 683 | + | v => panic!("expected float at {k}, got {v:?}"), | |
| 684 | + | }; | |
| 685 | + | ||
| 686 | + | // Founder: $8/$12/$18/$30 × 12 × 0.9 → rounded. | |
| 687 | + | assert_eq!(get_f("derived.annual_founding_basic"), 86.0); | |
| 688 | + | assert_eq!(get_f("derived.annual_founding_small_files"), 130.0); | |
| 689 | + | assert_eq!(get_f("derived.annual_founding_big_files"), 194.0); | |
| 690 | + | assert_eq!(get_f("derived.annual_founding_everything"), 324.0); | |
| 691 | + | ||
| 692 | + | // Standard: $16/$24/$36/$60 × 12 × 0.9 → rounded. | |
| 693 | + | assert_eq!(get_f("derived.annual_standard_basic"), 173.0); | |
| 694 | + | assert_eq!(get_f("derived.annual_standard_small_files"), 259.0); | |
| 695 | + | assert_eq!(get_f("derived.annual_standard_big_files"), 389.0); | |
| 696 | + | assert_eq!(get_f("derived.annual_standard_everything"), 648.0); | |
| 649 | 697 | } | |
| 650 | 698 | ||
| 651 | 699 | #[test] | |
| @@ -654,9 +702,9 @@ mod tests { | |||
| 654 | 702 | let out = a | |
| 655 | 703 | .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.") | |
| 656 | 704 | .unwrap(); | |
| 657 | - | // break_even_standard ≈ 27.6 → ceil → 28 (with full marginal model: | |
| 658 | - | // storage + weighted Stripe fee on creator subs + chargeback EV). | |
| 659 | - | assert_eq!(out, "Break-even at ~28 creators."); | |
| 705 | + | // break_even_standard ≈ 22.6 → ceil → 23 (with full marginal model at | |
| 706 | + | // standard $16/$24/$36/$60: 580 / (26.80 − 1.128) ≈ 22.6). | |
| 707 | + | assert_eq!(out, "Break-even at ~23 creators."); | |
| 660 | 708 | } | |
| 661 | 709 | ||
| 662 | 710 | #[test] | |
| @@ -679,8 +727,8 @@ mod tests { | |||
| 679 | 727 | let out = a | |
| 680 | 728 | .substitute("{{ derived.break_even_standard | round(1) }}") | |
| 681 | 729 | .unwrap(); | |
| 682 | - | // 580 / (26.80 − ~1.08) ≈ 22.5 | |
| 683 | - | assert_eq!(out, "22.5"); | |
| 730 | + | // 580 / (26.80 − ~1.128) ≈ 22.6 at canonical $16/$24/$36/$60. | |
| 731 | + | assert_eq!(out, "22.6"); | |
| 684 | 732 | } | |
| 685 | 733 | ||
| 686 | 734 | #[test] | |
| @@ -782,13 +830,9 @@ mod tests { | |||
| 782 | 830 | ||
| 783 | 831 | #[test] | |
| 784 | 832 | fn validation_catches_founding_above_standard() { | |
| 785 | - | let t = FIXTURE.replace( | |
| 786 | - | "[tiers.founding] | |
| 787 | - | # OPEN: founding ratio (currently 50% of standard) — pricing.md §7 item 2 | |
| 788 | - | basic = 5", | |
| 789 | - | "[tiers.founding] | |
| 790 | - | basic = 999", | |
| 791 | - | ); | |
| 833 | + | // `basic = 8` appears only in [tiers.founding] in the canonical fixture | |
| 834 | + | // (standard has `basic = 16`), so this substring is unambiguous. | |
| 835 | + | let t = FIXTURE.replace("basic = 8", "basic = 999"); | |
| 792 | 836 | let a = Assumptions::parse(&t).unwrap(); | |
| 793 | 837 | let err = a.validate().unwrap_err(); | |
| 794 | 838 | match err { |