| 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)]
|
| 398 |
399 |
|
cap_months: i64,
|
| 399 |
400 |
|
}
|
| 400 |
401 |
|
|
|
402 |
+ |
#[derive(Debug, Deserialize)]
|
|
403 |
+ |
struct TAnnualDiscount {
|
|
404 |
+ |
multiplier: f64,
|
|
405 |
+ |
}
|
|
406 |
+ |
|
| 401 |
407 |
|
#[derive(Debug, Deserialize)]
|
| 402 |
408 |
|
struct TCreatorMarginal {
|
| 403 |
409 |
|
storage_basic_gb: f64,
|
| 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 |
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 |
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 |
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 |
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 |
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 |
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 {
|