Skip to main content

max / makenotwork

Refined-A pricing reset ($8/14/18/24) + toml-driven tier config Reconciles the mbp Refined-A work (commit 4324c9b) against current main. Upstream had since removed the /pricing cost-allocation widget (bdd88fb), so the cost_allocation half of the mbp commit is obsolete and is NOT ported here — its inputs, derivation, and per-tier breakdown are archived in _private/docs/mnw/server-internal/business/cost_allocation_model_retired.md. Ported (the still-relevant half): - Numeric reset per pricing_interrogation_2026_07.md §6: standard 16/24/36/60 -> 8/14/18/24; founding 8/12/18/30 -> 4/7/9/12 (still 50%); Basic envelope 50GB/10MB -> 10GB/5MB; Small Files total 250GB -> 100GB; creator_marginal.storage_basic_gb 0.1 -> 0.05. - CreatorTier::{price_cents,max_file_bytes,max_storage_bytes} now delegate to a process-global TierPrices installed from assumptions.toml at boot (OnceLock); no more hardcoded match arms. Tests use install_test_default. - New [tier_bytes] block carries machine-readable byte counts alongside the [tier_limits] display strings; docengine validates the pair at boot via a new parse_size_bytes helper. - metrics.rs record_storage_fill_stats binds tier caps from TierPrices instead of an inline SQL VALUES table, fixing the existing big_files=1024GB / everything=5120GB drift against the enum (both 500GB). - stripe_seed_creator_tier_prices.sh reads tiers.standard from the toml via tomllib/tomli, retiring the STANDARD_MONTHLY bash array. - Sentinel-value tests across enums, creator_tiers, tier_prices, and docengine assumptions de-pinned to parametric invariants. Bump server 0.10.8 -> 0.10.9 (0.10.8 taken upstream by audit Run 22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-08 17:20 UTC
Commit: fbabd18be339a1a4a6fa34b028624fd3e6b0936f
Parent: 8aec19f
10 files changed, +643 insertions, -227 deletions
@@ -4318,7 +4318,7 @@ dependencies = [
4318 4318
4319 4319 [[package]]
4320 4320 name = "makenotwork"
4321 - version = "0.10.8"
4321 + version = "0.10.9"
4322 4322 dependencies = [
4323 4323 "ammonia",
4324 4324 "anyhow",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.10.8"
3 + version = "0.10.9"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6 # Server binary — never published to a registry. Marks the crate private so
@@ -27,9 +27,11 @@
27 27 # STRIPE_SECRET_KEY=sk_test_... ./deploy/stripe_seed_creator_tier_prices.sh --dry-run
28 28 #
29 29 # Reads the tier-price source of truth from
30 - # `_private/docs/mnw/server-internal/business/assumptions.toml` so docs, Rust
31 - # enum, and Stripe stay in lockstep. If you change prices, edit that toml
32 - # (and `CreatorTier::price_cents`), then re-run this.
30 + # `docs/business/assumptions.toml` so docs, Rust enum, and Stripe stay in
31 + # lockstep. A pricing change is a one-line edit to that toml + a re-run of
32 + # this script + a server restart (to load the new TierPrices global).
33 + # The CreatorTier accessors read from the toml at boot; there is no longer
34 + # a Rust `match` arm to update.
33 35 #
34 36 # Output: prints CREATOR_TIER_*_PRICE_ID env-var assignments suitable for
35 37 # pasting into the server's environment file.
@@ -51,18 +53,60 @@ done
51 53 API="https://api.stripe.com/v1"
52 54 AUTH=(-u "${STRIPE_SECRET_KEY}:")
53 55
54 - # Monthly prices in whole dollars. Founder = exactly 50% of standard. Annual =
55 - # monthly * 12 * 0.9, rounded to the nearest whole dollar (matches the displayed
56 - # prices in site-docs/about/pricing.md and site-docs/guide/tiers.md).
56 + # Monthly prices in whole dollars, loaded from assumptions.toml. Founder =
57 + # exactly 50% of standard. Annual = monthly * 12 * 0.9, rounded to the nearest
58 + # whole dollar (matches the displayed prices rendered by docengine).
57 59 #
58 - # Tiers: basic small_files big_files everything
59 - declare -A STANDARD_MONTHLY=(
60 - [basic]=16
61 - [small_files]=24
62 - [big_files]=36
63 - [everything]=60
60 + # ASSUMPTIONS_PATH lets an ops caller point at a non-default toml (dry-runs
61 + # against a proposed change, or a private-repo checkout). Defaults to the
62 + # canonical location relative to this script.
63 + SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
64 + ASSUMPTIONS_PATH="${ASSUMPTIONS_PATH:-${SCRIPT_DIR}/../docs/business/assumptions.toml}"
65 +
66 + if [[ ! -f "$ASSUMPTIONS_PATH" ]]; then
67 + echo "assumptions.toml not found at $ASSUMPTIONS_PATH" >&2
68 + echo "set ASSUMPTIONS_PATH= to override" >&2
69 + exit 2
70 + fi
71 +
72 + declare -A STANDARD_MONTHLY
73 + while IFS='=' read -r tier value; do
74 + STANDARD_MONTHLY[$tier]=$value
75 + done < <(python3 - "$ASSUMPTIONS_PATH" <<'PY'
76 + import sys
77 + # tomllib is stdlib on 3.11+; tomli is the drop-in for 3.9/3.10.
78 + try:
79 + import tomllib
80 + except ImportError:
81 + try:
82 + import tomli as tomllib
83 + except ImportError:
84 + sys.stderr.write(
85 + "tomllib (Python 3.11+) or tomli (pip install tomli) required\n"
86 + )
87 + sys.exit(3)
88 + with open(sys.argv[1], "rb") as f:
89 + doc = tomllib.load(f)
90 + std = doc["tiers"]["standard"]
91 + for tier in ("basic", "small_files", "big_files", "everything"):
92 + v = std[tier]
93 + if not isinstance(v, int):
94 + sys.stderr.write(
95 + f"tiers.standard.{tier} = {v!r} is not an integer (whole dollars required)\n"
96 + )
97 + sys.exit(3)
98 + print(f"{tier}={v}")
99 + PY
64 100 )
65 101
102 + # Sanity-check we got all four tiers.
103 + for tier in basic small_files big_files everything; do
104 + if [[ -z "${STANDARD_MONTHLY[$tier]:-}" ]]; then
105 + echo "missing tiers.standard.${tier} in $ASSUMPTIONS_PATH" >&2
106 + exit 2
107 + fi
108 + done
109 +
66 110 # Product display names — these appear on Stripe dashboard and customer
67 111 # receipts. One product per tier; four prices hang off each.
68 112 declare -A PRODUCT_NAME=(
@@ -88,36 +88,60 @@ backup_pct_of_server = 0.20
88 88 # Founder pricing — exactly 50% of standard sticker, locked for life. Active
89 89 # until the founder window closes (1,000 creators OR exit-beta, whichever
90 90 # first). Decision 2026-05-18; raised to 8/12/18/30 on 2026-05-31 to track
91 - # the standard-tier raise (see memory `project_founder_pricing.md`).
92 - basic = 8
93 - small_files = 12
94 - big_files = 18
95 - everything = 30
91 + # the standard-tier raise; reset to Refined-A (4/7/9/12) on 2026-07-08 per
92 + # pricing_interrogation_2026_07.md §6.
93 + basic = 4
94 + small_files = 7
95 + big_files = 9
96 + everything = 12
96 97
97 98 [tiers.standard]
98 - # Post-founder sticker — set 2026-05-31 to clear the irreducible
99 - # ~$5/creator/mo support floor at every tier with even-dollar prices.
100 - # Founder rate is exactly 50% of these. See launchplan_final.md §4.5.
101 - basic = 16
102 - small_files = 24
103 - big_files = 36
104 - everything = 60
99 + # Refined-A sticker — set 2026-07-08 per pricing_interrogation_2026_07.md §6.
100 + # Priced against residency-scale labor ($41.5/hr Y1 support, ~$50/hr blended
101 + # engineering) rather than the earlier $80/hr market rate, with right-sized
102 + # Basic (10GB) and Small Files (100GB) envelopes. Founder rate is exactly
103 + # 50% of these. History: 16/24/36/60 (2026-05-31), 12/18/24/40 (earlier).
104 + basic = 8
105 + small_files = 14
106 + big_files = 18
107 + everything = 24
105 108
106 109 # ─── Tier envelope limits (storage caps + file size caps) ────────────────
107 110 # Per-tier file-size envelope. Public-facing copy substitutes these via
108 111 # {{ tier_limits.* }} so the toml is the single source of truth and a future
109 112 # limit change is a one-line edit. Stored as display strings (no NBSP, no
110 113 # trailing periods) because that's the form the docs always need.
114 + #
115 + # `[tier_bytes]` below carries the same envelopes as machine-readable integer
116 + # byte counts — that's the form Rust needs for upload gating, storage-cap
117 + # accounting, and the metrics fill-ratio SQL. Docengine validates that each
118 + # `tier_bytes.<k>` parses back to the matching `tier_limits.<k>` string, so
119 + # a drift between the two forms fails at boot instead of shipping to prod.
111 120 [tier_limits]
112 - basic_per_file = "10MB"
113 - basic_total = "50GB"
121 + # Refined-A envelopes (2026-07-08): Basic shrunk to 5MB/10GB and Small Files
122 + # total shrunk to 100GB to make the tier ladder honest to actual content shape
123 + # rather than "which tier gives me headroom I'll never use." Big Files and
124 + # Everything envelopes unchanged.
125 + basic_per_file = "5MB"
126 + basic_total = "10GB"
114 127 small_files_per_file = "500MB"
115 - small_files_total = "250GB"
128 + small_files_total = "100GB"
116 129 big_files_per_file = "20GB"
117 130 big_files_total = "500GB"
118 131 everything_per_file = "20GB"
119 132 everything_total = "500GB"
120 133
134 + [tier_bytes]
135 + # Binary units (KB = 1024 B). Must match [tier_limits] display strings above.
136 + basic_per_file = 5242880 # 5 MB
137 + basic_total = 10737418240 # 10 GB
138 + small_files_per_file = 524288000 # 500 MB
139 + small_files_total = 107374182400 # 100 GB
140 + big_files_per_file = 21474836480 # 20 GB
141 + big_files_total = 536870912000 # 500 GB
142 + everything_per_file = 21474836480 # 20 GB
143 + everything_total = 536870912000 # 500 GB
144 +
121 145
122 146 [annual_discount]
123 147 # Annual billing is 10% off the monthly × 12 total at every tier, founder
@@ -183,7 +207,7 @@ lock_duration = "lifetime" # OPEN: §7 item 4 — lifetime-of-subscri
183 207 # ─── Per-creator marginal cost inputs (canonical: assumptions.md A1-A10) ─
184 208 # All currently unmeasured — pre-launch placeholders.
185 209 [creator_marginal]
186 - storage_basic_gb = 0.1 # A1: ~100 MB
210 + storage_basic_gb = 0.05 # A1: ~50 MB (right-sized 2026-07-08 alongside 10GB Basic envelope)
187 211 storage_small_files_gb = 2 # A2: ~2 GB
188 212 storage_big_files_gb = 10 # A3: ~10 GB
189 213 storage_everything_gb = 30 # A10: tier-dependent, placeholder
@@ -961,132 +961,81 @@ mod tests {
961 961 assert_eq!(CreatorTier::Everything.label(), "Everything");
962 962 }
963 963
964 - // ── CreatorTier::price_cents ────────────────────────────────────────
965 -
966 - #[test]
967 - fn price_basic_is_sixteen_dollars() {
968 - assert_eq!(CreatorTier::Basic.price_cents(), 1600);
969 - }
970 -
971 - #[test]
972 - fn price_small_files_is_twenty_four_dollars() {
973 - assert_eq!(CreatorTier::SmallFiles.price_cents(), 2400);
974 - }
975 -
976 - #[test]
977 - fn price_big_files_is_thirty_six_dollars() {
978 - assert_eq!(CreatorTier::BigFiles.price_cents(), 3600);
979 - }
980 -
981 - #[test]
982 - fn price_everything_is_sixty_dollars() {
983 - assert_eq!(CreatorTier::Everything.price_cents(), 6000);
984 - }
985 -
986 - #[test]
987 - fn prices_are_strictly_increasing() {
988 - let tiers = [
964 + // ── CreatorTier price + envelope invariants ────────────────────────
965 + //
966 + // Concrete cents/bytes come from `assumptions.toml` via the installed
967 + // `TierPrices` global. Tests pin only structural invariants (positive,
968 + // monotone, per-file ≤ storage) so a future toml edit doesn't rewrite
969 + // this file. Literal-value pins live in `docs/business/assumptions.toml`
970 + // itself and are guarded by the docengine `tier_bytes ↔ tier_limits`
971 + // validator.
972 +
973 + fn all_tiers() -> [CreatorTier; 4] {
974 + [
989 975 CreatorTier::Basic,
990 976 CreatorTier::SmallFiles,
991 977 CreatorTier::BigFiles,
992 978 CreatorTier::Everything,
993 - ];
979 + ]
980 + }
981 +
982 + #[test]
983 + fn prices_positive_and_strictly_increasing() {
984 + crate::tier_prices::TierPrices::install_test_default();
985 + let tiers = all_tiers();
986 + assert!(tiers[0].price_cents() > 0);
994 987 for pair in tiers.windows(2) {
995 988 assert!(
996 989 pair[0].price_cents() < pair[1].price_cents(),
997 990 "{:?} should cost less than {:?}",
998 - pair[0],
999 - pair[1],
991 + pair[0], pair[1],
1000 992 );
1001 993 }
1002 994 }
1003 995
1004 - // ── CreatorTier::max_file_bytes ─────────────────────────────────────
1005 -
1006 - #[test]
1007 - fn max_file_basic_is_10mb() {
1008 - assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
1009 - }
1010 -
1011 - #[test]
1012 - fn max_file_small_files_is_500mb() {
1013 - assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
1014 - }
1015 -
1016 - #[test]
1017 - fn max_file_big_files_is_20gb() {
1018 - assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1019 - }
1020 -
1021 - #[test]
1022 - fn max_file_everything_matches_big_files() {
1023 - assert_eq!(
1024 - CreatorTier::Everything.max_file_bytes(),
1025 - CreatorTier::BigFiles.max_file_bytes(),
1026 - );
1027 - }
1028 -
1029 996 #[test]
1030 997 fn max_file_bytes_non_decreasing() {
1031 - let tiers = [
1032 - CreatorTier::Basic,
1033 - CreatorTier::SmallFiles,
1034 - CreatorTier::BigFiles,
1035 - CreatorTier::Everything,
1036 - ];
998 + crate::tier_prices::TierPrices::install_test_default();
999 + let tiers = all_tiers();
1000 + assert!(tiers[0].max_file_bytes() > 0);
1037 1001 for pair in tiers.windows(2) {
1038 1002 assert!(
1039 1003 pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
1040 1004 "{:?} file limit should not exceed {:?}",
1041 - pair[0],
1042 - pair[1],
1005 + pair[0], pair[1],
1043 1006 );
1044 1007 }
1045 1008 }
1046 1009
1047 - // ── CreatorTier::max_storage_bytes ───────────────────────────────────
1048 -
1049 1010 #[test]
1050 - fn max_storage_basic_is_50gb() {
1051 - assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1052 - }
1053 -
1054 - #[test]
1055 - fn max_storage_small_files_is_250gb() {
1056 - assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
1057 - }
1058 -
1059 - #[test]
1060 - fn max_storage_big_files_is_500gb() {
1061 - assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1011 + fn max_storage_bytes_non_decreasing() {
1012 + crate::tier_prices::TierPrices::install_test_default();
1013 + let tiers = all_tiers();
1014 + assert!(tiers[0].max_storage_bytes() > 0);
1015 + for pair in tiers.windows(2) {
1016 + assert!(
1017 + pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1018 + "{:?} storage limit should not exceed {:?}",
1019 + pair[0], pair[1],
1020 + );
1021 + }
1062 1022 }
1063 1023
1064 1024 #[test]
1065 - fn max_storage_everything_matches_big_files() {
1025 + fn top_tier_matches_big_files_envelope() {
1026 + // BigFiles and Everything share the same envelope by product design.
1027 + // (Everything differentiates on features, not caps.)
1028 + crate::tier_prices::TierPrices::install_test_default();
1029 + assert_eq!(
1030 + CreatorTier::Everything.max_file_bytes(),
1031 + CreatorTier::BigFiles.max_file_bytes(),
1032 + );
1066 1033 assert_eq!(
1067 1034 CreatorTier::Everything.max_storage_bytes(),
1068 1035 CreatorTier::BigFiles.max_storage_bytes(),
1069 1036 );
1070 1037 }
1071 1038
1072 - #[test]
1073 - fn max_storage_non_decreasing() {
1074 - let tiers = [
1075 - CreatorTier::Basic,
1076 - CreatorTier::SmallFiles,
1077 - CreatorTier::BigFiles,
1078 - CreatorTier::Everything,
1079 - ];
1080 - for pair in tiers.windows(2) {
1081 - assert!(
1082 - pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1083 - "{:?} storage limit should not exceed {:?}",
1084 - pair[0],
1085 - pair[1],
1086 - );
1087 - }
1088 - }
1089 -
1090 1039 // ── CreatorTier::allows_file_uploads ─────────────────────────────────
1091 1040
1092 1041 #[test]
@@ -1200,11 +1149,13 @@ mod tests {
1200 1149
1201 1150 #[test]
1202 1151 fn basic_file_limit_less_than_storage_limit() {
1152 + crate::tier_prices::TierPrices::install_test_default();
1203 1153 assert!(CreatorTier::Basic.max_file_bytes() < CreatorTier::Basic.max_storage_bytes());
1204 1154 }
1205 1155
1206 1156 #[test]
1207 1157 fn every_tier_file_limit_within_storage_limit() {
1158 + crate::tier_prices::TierPrices::install_test_default();
1208 1159 for tier in [
1209 1160 CreatorTier::Basic,
1210 1161 CreatorTier::SmallFiles,
@@ -583,34 +583,23 @@ impl CreatorTier {
583 583 }
584 584 }
585 585
586 - /// Monthly price in cents.
586 + /// Monthly standard price in cents. Reads from the process-global
587 + /// `TierPrices` installed at startup from `assumptions.toml`. See
588 + /// `crate::tier_prices` for the OnceLock and test-setup helper.
587 589 pub fn price_cents(&self) -> i32 {
588 - match self {
589 - Self::Basic => 1600,
590 - Self::SmallFiles => 2400,
591 - Self::BigFiles => 3600,
592 - Self::Everything => 6000,
593 - }
590 + crate::tier_prices::TierPrices::global().price_cents_for(*self)
594 591 }
595 592
596 - /// Maximum per-file upload size in bytes.
593 + /// Maximum per-file upload size in bytes. Reads from the global
594 + /// `TierPrices` (see `price_cents`).
597 595 pub fn max_file_bytes(&self) -> i64 {
598 - match self {
599 - Self::Basic => 10 * 1024 * 1024, // 10 MB
600 - Self::SmallFiles => 500 * 1024 * 1024, // 500 MB
601 - Self::BigFiles => 20 * 1024 * 1024 * 1024, // 20 GB
602 - Self::Everything => 20 * 1024 * 1024 * 1024, // 20 GB
603 - }
596 + crate::tier_prices::TierPrices::global().max_file_bytes_for(*self)
604 597 }
605 598
606 - /// Maximum total storage in bytes.
599 + /// Maximum total storage in bytes. Reads from the global `TierPrices`
600 + /// (see `price_cents`).
607 601 pub fn max_storage_bytes(&self) -> i64 {
608 - match self {
609 - Self::Basic => 50 * 1024 * 1024 * 1024, // 50 GB
610 - Self::SmallFiles => 250 * 1024 * 1024 * 1024, // 250 GB
611 - Self::BigFiles => 500 * 1024 * 1024 * 1024, // 500 GB
612 - Self::Everything => 500 * 1024 * 1024 * 1024, // 500 GB
613 - }
602 + crate::tier_prices::TierPrices::global().max_storage_bytes_for(*self)
614 603 }
615 604
616 605 /// Whether this tier allows non-cover file uploads (audio, downloads, insertions).
@@ -1240,26 +1229,75 @@ mod tests {
1240 1229
1241 1230 #[test]
1242 1231 fn creator_tier_label_and_price() {
1232 + crate::tier_prices::TierPrices::install_test_default();
1243 1233 assert_eq!(CreatorTier::Basic.label(), "Basic");
1244 1234 assert_eq!(CreatorTier::SmallFiles.label(), "Small Files");
1245 - assert_eq!(CreatorTier::Basic.price_cents(), 1600);
1246 - assert_eq!(CreatorTier::Everything.price_cents(), 6000);
1235 + // Prices come from assumptions.toml. Assert invariants — founder = std/2,
1236 + // monotone across tiers — not literal cents, so a future toml edit doesn't
1237 + // break this test.
1238 + let tiers = [
1239 + CreatorTier::Basic,
1240 + CreatorTier::SmallFiles,
1241 + CreatorTier::BigFiles,
1242 + CreatorTier::Everything,
1243 + ];
1244 + for pair in tiers.windows(2) {
1245 + assert!(
1246 + pair[0].price_cents() < pair[1].price_cents(),
1247 + "{:?} price_cents ({}) should be < {:?} ({})",
1248 + pair[0], pair[0].price_cents(), pair[1], pair[1].price_cents(),
1249 + );
1250 + }
1251 + assert!(CreatorTier::Basic.price_cents() > 0);
1247 1252 }
1248 1253
1249 1254 #[test]
1250 1255 fn creator_tier_file_limits() {
1251 - assert_eq!(CreatorTier::Basic.max_file_bytes(), 10 * 1024 * 1024);
1252 - assert_eq!(CreatorTier::SmallFiles.max_file_bytes(), 500 * 1024 * 1024);
1253 - assert_eq!(CreatorTier::BigFiles.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1254 - assert_eq!(CreatorTier::Everything.max_file_bytes(), 20 * 1024 * 1024 * 1024);
1256 + crate::tier_prices::TierPrices::install_test_default();
1257 + // File caps monotone across tiers; Basic ≤ SmallFiles ≤ BigFiles == Everything.
1258 + let tiers = [
1259 + CreatorTier::Basic,
1260 + CreatorTier::SmallFiles,
1261 + CreatorTier::BigFiles,
1262 + CreatorTier::Everything,
1263 + ];
1264 + for pair in tiers.windows(2) {
1265 + assert!(
1266 + pair[0].max_file_bytes() <= pair[1].max_file_bytes(),
1267 + "{:?} max_file_bytes ({}) should be <= {:?} ({})",
1268 + pair[0], pair[0].max_file_bytes(), pair[1], pair[1].max_file_bytes(),
1269 + );
1270 + }
1271 + assert!(CreatorTier::Basic.max_file_bytes() > 0);
1255 1272 }
1256 1273
1257 1274 #[test]
1258 1275 fn creator_tier_storage_limits() {
1259 - assert_eq!(CreatorTier::Basic.max_storage_bytes(), 50 * 1024 * 1024 * 1024);
1260 - assert_eq!(CreatorTier::SmallFiles.max_storage_bytes(), 250 * 1024 * 1024 * 1024);
1261 - assert_eq!(CreatorTier::BigFiles.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1262 - assert_eq!(CreatorTier::Everything.max_storage_bytes(), 500 * 1024 * 1024 * 1024);
1276 + crate::tier_prices::TierPrices::install_test_default();
1277 + let tiers = [
1278 + CreatorTier::Basic,
1279 + CreatorTier::SmallFiles,
1280 + CreatorTier::BigFiles,
1281 + CreatorTier::Everything,
1282 + ];
1283 + for pair in tiers.windows(2) {
1284 + assert!(
1285 + pair[0].max_storage_bytes() <= pair[1].max_storage_bytes(),
1286 + "{:?} max_storage_bytes ({}) should be <= {:?} ({})",
1287 + pair[0], pair[0].max_storage_bytes(), pair[1], pair[1].max_storage_bytes(),
1288 + );
1289 + }
1290 + assert!(CreatorTier::Basic.max_storage_bytes() > 0);
1291 + // Every tier's per-file cap must fit in its total storage cap or uploads
1292 + // are impossible. Catches an accidental toml edit that shrinks a total
1293 + // below the per-file limit.
1294 + for &tier in &tiers {
1295 + assert!(
1296 + tier.max_file_bytes() <= tier.max_storage_bytes(),
1297 + "{tier:?}: file cap ({}) exceeds storage cap ({})",
1298 + tier.max_file_bytes(), tier.max_storage_bytes(),
1299 + );
1300 + }
1263 1301 }
1264 1302
1265 1303 #[test]
@@ -397,7 +397,15 @@ async fn main() {
397 397 stripe,
398 398 email,
399 399 docs,
400 - tier_prices: makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions),
400 + tier_prices: {
401 + let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions);
402 + // Install as the process-global for CreatorTier accessors
403 + // (price_cents, max_file_bytes, max_storage_bytes). Must precede
404 + // any code path that constructs a CreatorTier and calls one of
405 + // these methods.
406 + tp.clone().install_global();
407 + tp
408 + },
401 409 runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions),
402 410 pricing_comparison: makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path),
403 411 scanner,
@@ -227,18 +227,26 @@ pub async fn record_pg_stat_activity(pool: &sqlx::PgPool) -> Option<(i64, i64)>
227 227 /// re-pricing. This gauge is the canonical input for that threshold.
228 228 #[tracing::instrument(skip_all)]
229 229 pub async fn record_storage_fill_stats(pool: &sqlx::PgPool) {
230 - // Tier cap table: kept in SQL because the values rarely change and
231 - // mirror `CreatorTier::max_storage_bytes`. Update both sites if a tier
232 - // cap moves. Tiers without a creator_subscriptions row are excluded —
233 - // a user without an active subscription has no committed storage cap.
230 + // Tier caps come from the installed TierPrices global (loaded from
231 + // assumptions.toml at startup). Binding them here means a single edit
232 + // to the toml propagates to both `CreatorTier::max_storage_bytes` and
233 + // the metrics query — no chance of SQL/Rust drift like the previous
234 + // inline VALUES table had.
235 + use crate::db::CreatorTier;
236 + let tp = crate::tier_prices::TierPrices::global();
237 + let basic = tp.max_storage_bytes_for(CreatorTier::Basic);
238 + let small_files = tp.max_storage_bytes_for(CreatorTier::SmallFiles);
239 + let big_files = tp.max_storage_bytes_for(CreatorTier::BigFiles);
240 + let everything = tp.max_storage_bytes_for(CreatorTier::Everything);
241 +
234 242 let row: Result<(i64, i64), _> = sqlx::query_as(
235 243 r#"
236 244 WITH tier_caps(tier, cap_bytes) AS (
237 245 VALUES
238 - ('basic', 50::bigint * 1024 * 1024 * 1024),
239 - ('small_files', 250::bigint * 1024 * 1024 * 1024),
240 - ('big_files', 1024::bigint * 1024 * 1024 * 1024),
241 - ('everything', 5120::bigint * 1024 * 1024 * 1024)
246 + ('basic'::text, $1::bigint),
247 + ('small_files'::text, $2::bigint),
248 + ('big_files'::text, $3::bigint),
249 + ('everything'::text, $4::bigint)
242 250 )
243 251 SELECT
244 252 COALESCE(SUM(u.storage_used_bytes), 0)::bigint AS used,
@@ -249,6 +257,10 @@ pub async fn record_storage_fill_stats(pool: &sqlx::PgPool) {
249 257 JOIN tier_caps tc ON tc.tier = cs.tier
250 258 "#,
251 259 )
260 + .bind(basic)
261 + .bind(small_files)
262 + .bind(big_files)
263 + .bind(everything)
252 264 .fetch_one(pool)
253 265 .await;
254 266
@@ -7,9 +7,21 @@
7 7 //! Missing or wrong-typed keys panic at startup (same pattern as
8 8 //! `Assumptions::validate` failure in main.rs). Production never serves with a
9 9 //! half-loaded `TierPrices`.
10 + //!
11 + //! `TierPrices::install_global` writes the loaded instance into a process-wide
12 + //! `OnceLock` so `CreatorTier` accessors (`price_cents`, `max_file_bytes`,
13 + //! `max_storage_bytes`) can read from it without threading state through every
14 + //! caller. `main.rs` calls it before the server binds; tests call
15 + //! `install_test_default` before touching CreatorTier.
16 +
17 + use std::sync::OnceLock;
10 18
11 19 use docengine::{Assumptions, LookupValue};
12 20
21 + use crate::db::CreatorTier;
22 +
23 + static GLOBAL: OnceLock<TierPrices> = OnceLock::new();
24 +
13 25 #[derive(Clone, Debug, Default)]
14 26 pub struct TierPrices {
15 27 // Standard monthly (post-founder sticker rates).
@@ -41,6 +53,17 @@ pub struct TierPrices {
41 53 pub small_files_total: String,
42 54 pub big_files_total: String,
43 55 pub everything_total: String,
56 + // Same envelopes as machine-readable byte counts (from [tier_bytes]).
57 + // The docengine validator asserts these parse back to the display strings
58 + // above, so drift between the two forms fails at boot.
59 + pub basic_per_file_bytes: i64,
60 + pub small_files_per_file_bytes: i64,
61 + pub big_files_per_file_bytes: i64,
62 + pub everything_per_file_bytes: i64,
63 + pub basic_total_bytes: i64,
64 + pub small_files_total_bytes: i64,
65 + pub big_files_total_bytes: i64,
66 + pub everything_total_bytes: i64,
44 67 // Founder cohort cap, display string with thousands separator ("1,000").
45 68 pub cohort_cap_display: String,
46 69 }
@@ -72,9 +95,82 @@ impl TierPrices {
72 95 small_files_total: str_at(a, "tier_limits.small_files_total"),
73 96 big_files_total: str_at(a, "tier_limits.big_files_total"),
74 97 everything_total: str_at(a, "tier_limits.everything_total"),
98 + basic_per_file_bytes: bytes_at(a, "tier_bytes.basic_per_file"),
99 + small_files_per_file_bytes: bytes_at(a, "tier_bytes.small_files_per_file"),
100 + big_files_per_file_bytes: bytes_at(a, "tier_bytes.big_files_per_file"),
101 + everything_per_file_bytes: bytes_at(a, "tier_bytes.everything_per_file"),
102 + basic_total_bytes: bytes_at(a, "tier_bytes.basic_total"),
103 + small_files_total_bytes: bytes_at(a, "tier_bytes.small_files_total"),
104 + big_files_total_bytes: bytes_at(a, "tier_bytes.big_files_total"),
105 + everything_total_bytes: bytes_at(a, "tier_bytes.everything_total"),
75 106 cohort_cap_display: str_at(a, "cohort.cap_display"),
76 107 }
77 108 }
109 +
110 + /// Monthly standard price in cents for the given tier. Backs
111 + /// `CreatorTier::price_cents` — see the OnceLock note at the top of this
112 + /// module.
113 + pub fn price_cents_for(&self, tier: CreatorTier) -> i32 {
114 + (match tier {
115 + CreatorTier::Basic => self.basic_std,
116 + CreatorTier::SmallFiles => self.small_files_std,
117 + CreatorTier::BigFiles => self.big_files_std,
118 + CreatorTier::Everything => self.everything_std,
119 + }) * 100
120 + }
121 +
122 + /// Per-upload byte cap for the given tier.
123 + pub fn max_file_bytes_for(&self, tier: CreatorTier) -> i64 {
124 + match tier {
125 + CreatorTier::Basic => self.basic_per_file_bytes,
126 + CreatorTier::SmallFiles => self.small_files_per_file_bytes,
127 + CreatorTier::BigFiles => self.big_files_per_file_bytes,
128 + CreatorTier::Everything => self.everything_per_file_bytes,
129 + }
130 + }
131 +
132 + /// Total storage byte cap for the given tier.
133 + pub fn max_storage_bytes_for(&self, tier: CreatorTier) -> i64 {
134 + match tier {
135 + CreatorTier::Basic => self.basic_total_bytes,
136 + CreatorTier::SmallFiles => self.small_files_total_bytes,
137 + CreatorTier::BigFiles => self.big_files_total_bytes,
138 + CreatorTier::Everything => self.everything_total_bytes,
139 + }
140 + }
141 +
142 + /// Install this instance as the process-wide `CreatorTier` config source.
143 + /// Called from `main.rs` once, before any request handling. Subsequent
144 + /// calls are ignored (OnceLock semantics); production installs exactly
145 + /// once.
146 + pub fn install_global(self) {
147 + let _ = GLOBAL.set(self);
148 + }
149 +
150 + /// Read the installed global. Panics if `install_global` hasn't been
151 + /// called — same failure mode as boot-time toml validation.
152 + pub fn global() -> &'static TierPrices {
153 + GLOBAL.get().expect(
154 + "TierPrices::install_global was not called before CreatorTier accessor use — \
155 + call install_global in main.rs or TierPrices::install_test_default in a test",
156 + )
157 + }
158 +
159 + /// Install the canonical fixture into the global slot for test use.
160 + /// Idempotent; safe to call from multiple tests concurrently.
161 + #[cfg(any(test, debug_assertions))]
162 + pub fn install_test_default() {
163 + // If already installed (either by an earlier test or by an integration
164 + // harness), leave it — the values are stable across tests.
165 + if GLOBAL.get().is_some() {
166 + return;
167 + }
168 + // Path is relative to the crate root at test time.
169 + let a = Assumptions::load("docs/business/assumptions.toml")
170 + .expect("test setup: load canonical assumptions.toml");
171 + let tp = TierPrices::from_assumptions(&a);
172 + let _ = GLOBAL.set(tp);
173 + }
78 174 }
79 175
80 176 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
@@ -179,6 +275,15 @@ fn int_at(a: &Assumptions, key: &str) -> i32 {
179 275 }
180 276 }
181 277
278 + /// Byte counts are i64 (Basic total = 10GB fits, Everything total = 500GB fits;
279 + /// hitting i32 max is a ~2 GB tier which we'd never allow, but keep the room).
280 + fn bytes_at(a: &Assumptions, key: &str) -> i64 {
281 + match a.get(key) {
282 + Some(LookupValue::Int(n)) => *n,
283 + other => panic!("expected integer at {key}, got {other:?}"),
284 + }
285 + }
286 +
182 287 fn str_at(a: &Assumptions, key: &str) -> String {
183 288 match a.get(key) {
184 289 Some(LookupValue::String(s)) => s.clone(),
@@ -222,25 +327,69 @@ mod tests {
222 327
223 328 #[test]
224 329 fn from_canonical_assumptions_populates_every_field() {
330 + // Guards every key TierPrices reads. If a future toml edit removes
331 + // one of these or flips its type, the panic in `from_assumptions`
332 + // fires at startup; this test catches it at PR time instead. All
333 + // assertions are *structural* invariants — the literal numbers
334 + // live in the toml itself.
225 335 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
226 336 let p = TierPrices::from_assumptions(&a);
227 337
228 - // Sanity-check sentinels (values match the toml; if they change in
229 - // toml, update here too).
230 - assert_eq!(p.basic_std, 16);
231 - assert_eq!(p.everything_std, 60);
232 - assert_eq!(p.basic_founder, 8);
233 - assert_eq!(p.annual_basic_std, 173);
234 - assert_eq!(p.annual_everything_founder, 324);
235 - assert_eq!(p.basic_per_file, "10MB");
236 - assert_eq!(p.everything_total, "500GB");
237 - assert_eq!(p.cohort_cap_display, "1,000");
238 -
239 - // Cards iteration produces the four canonical rows.
338 + // Every price/annual field must be positive.
339 + for (name, v) in [
340 + ("basic_std", p.basic_std),
341 + ("small_files_std", p.small_files_std),
342 + ("big_files_std", p.big_files_std),
343 + ("everything_std", p.everything_std),
344 + ("basic_founder", p.basic_founder),
345 + ("small_files_founder", p.small_files_founder),
346 + ("big_files_founder", p.big_files_founder),
347 + ("everything_founder", p.everything_founder),
348 + ("annual_basic_std", p.annual_basic_std),
349 + ("annual_small_files_std", p.annual_small_files_std),
350 + ("annual_big_files_std", p.annual_big_files_std),
351 + ("annual_everything_std", p.annual_everything_std),
352 + ("annual_basic_founder", p.annual_basic_founder),
353 + ("annual_small_files_founder", p.annual_small_files_founder),
354 + ("annual_big_files_founder", p.annual_big_files_founder),
355 + ("annual_everything_founder", p.annual_everything_founder),
356 + ] {
357 + assert!(v > 0, "{name} = {v} must be positive");
358 + }
359 +
360 + // Founder is exactly 50% of standard at every tier (policy invariant
361 + // — enforced by docengine's `founding ≤ standard` and by pricing
362 + // policy in `pricing.md`).
363 + assert_eq!(p.basic_founder * 2, p.basic_std, "founder must be 50% of standard");
364 + assert_eq!(p.small_files_founder * 2, p.small_files_std);
365 + assert_eq!(p.big_files_founder * 2, p.big_files_std);
366 + assert_eq!(p.everything_founder * 2, p.everything_std);
367 +
368 + // Standard is monotone across the tier ladder.
369 + assert!(p.basic_std < p.small_files_std);
370 + assert!(p.small_files_std < p.big_files_std);
371 + assert!(p.big_files_std < p.everything_std);
372 +
373 + // Envelope byte-counts are positive and (for storage totals) non-decreasing.
374 + assert!(p.basic_per_file_bytes > 0);
375 + assert!(p.basic_total_bytes > 0);
376 + assert!(p.basic_total_bytes <= p.small_files_total_bytes);
377 + assert!(p.small_files_total_bytes <= p.big_files_total_bytes);
378 + assert_eq!(p.big_files_total_bytes, p.everything_total_bytes);
379 + assert_eq!(p.big_files_per_file_bytes, p.everything_per_file_bytes);
380 +
381 + // Display strings are non-empty.
382 + assert!(!p.basic_per_file.is_empty());
383 + assert!(!p.everything_total.is_empty());
384 + assert!(!p.cohort_cap_display.is_empty());
385 +
386 + // Cards iteration produces the four canonical rows in canonical order.
240 387 let cards = p.cards();
241 388 assert_eq!(cards.len(), 4);
242 389 assert_eq!(cards[0].key, "basic");
390 + assert_eq!(cards[1].key, "small_files");
391 + assert_eq!(cards[2].key, "big_files");
243 392 assert_eq!(cards[3].key, "everything");
244 - assert_eq!(cards[1].standard_monthly, 24);
393 + assert_eq!(cards[1].standard_monthly, p.small_files_std);
245 394 }
246 395 }
@@ -214,6 +214,33 @@ impl Assumptions {
214 214 ));
215 215 }
216 216
217 + // tier_bytes.<k> must match the parsed tier_limits.<k> display string.
218 + // Drift here would let a docs edit ("10 MB → 20 MB") ship without the
219 + // upload gate agreeing, or vice versa. Binary units (KB = 1024 B).
220 + let l = &t.tier_limits;
221 + let b = &t.tier_bytes;
222 + for (name, disp, byt) in [
223 + ("basic_per_file", &l.basic_per_file, b.basic_per_file),
224 + ("basic_total", &l.basic_total, b.basic_total),
225 + ("small_files_per_file", &l.small_files_per_file, b.small_files_per_file),
226 + ("small_files_total", &l.small_files_total, b.small_files_total),
227 + ("big_files_per_file", &l.big_files_per_file, b.big_files_per_file),
228 + ("big_files_total", &l.big_files_total, b.big_files_total),
229 + ("everything_per_file", &l.everything_per_file, b.everything_per_file),
230 + ("everything_total", &l.everything_total, b.everything_total),
231 + ] {
232 + match parse_size_bytes(disp) {
233 + Ok(parsed) if parsed == byt => {}
234 + Ok(parsed) => failures.push(format!(
235 + "tier_limits.{name} = {disp:?} parses to {parsed} bytes, \
236 + but tier_bytes.{name} = {byt}"
237 + )),
238 + Err(e) => failures.push(format!(
239 + "tier_limits.{name} = {disp:?} could not be parsed: {e}"
240 + )),
241 + }
242 + }
243 +
217 244 if failures.is_empty() {
218 245 Ok(())
219 246 } else {
@@ -331,6 +358,8 @@ struct Typed {
331 358 stripe: TStripe,
332 359 tiers: TTiers,
333 360 tier_mix: TTierMix,
361 + tier_limits: TTierLimits,
362 + tier_bytes: TTierBytes,
334 363 reserve: TReserve,
335 364 cohort: TCohort,
336 365 creator_marginal: TCreatorMarginal,
@@ -338,6 +367,30 @@ struct Typed {
338 367 }
339 368
340 369 #[derive(Debug, Deserialize)]
370 + struct TTierLimits {
371 + basic_per_file: String,
372 + basic_total: String,
373 + small_files_per_file: String,
374 + small_files_total: String,
375 + big_files_per_file: String,
376 + big_files_total: String,
377 + everything_per_file: String,
378 + everything_total: String,
379 + }
380 +
381 + #[derive(Debug, Deserialize)]
382 + struct TTierBytes {
383 + basic_per_file: i64,
384 + basic_total: i64,
385 + small_files_per_file: i64,
386 + small_files_total: i64,
387 + big_files_per_file: i64,
388 + big_files_total: i64,
389 + everything_per_file: i64,
390 + everything_total: i64,
391 + }
392 +
393 + #[derive(Debug, Deserialize)]
341 394 struct TExpenses {
342 395 #[serde(rename = "F_monthly")]
343 396 f_monthly: f64,
@@ -416,6 +469,30 @@ struct TCreatorMarginal {
416 469
417 470 // ─── Walking + derived ────────────────────────────────────────────────────
418 471
472 + /// Parse a size display string ("10MB", "500GB") to bytes. Binary units
473 + /// (KB = 1024 B). Accepts a decimal number and a case-insensitive suffix
474 + /// with no space between them, matching the `[tier_limits]` convention.
475 + fn parse_size_bytes(s: &str) -> Result<i64, String> {
476 + let s = s.trim();
477 + let split = s
478 + .find(|c: char| c.is_ascii_alphabetic())
479 + .ok_or_else(|| format!("no unit suffix in {s:?}"))?;
480 + let (num, unit) = s.split_at(split);
481 + let num: f64 = num
482 + .trim()
483 + .parse()
484 + .map_err(|e| format!("bad number in {s:?}: {e}"))?;
485 + let mult: f64 = match unit.trim().to_ascii_uppercase().as_str() {
486 + "B" => 1.0,
487 + "KB" => 1024.0,
488 + "MB" => 1024.0 * 1024.0,
489 + "GB" => 1024.0 * 1024.0 * 1024.0,
490 + "TB" => 1024.0_f64.powi(4),
491 + other => return Err(format!("unknown unit {other:?} in {s:?}")),
492 + };
493 + Ok((num * mult).round() as i64)
494 + }
495 +
419 496 fn walk_value(value: &toml::Value, prefix: String, out: &mut HashMap<String, LookupValue>) {
420 497 match value {
421 498 toml::Value::Table(table) => {
@@ -595,26 +672,44 @@ mod tests {
595 672 #[test]
596 673 fn derived_values_match_worked_examples() {
597 674 let a = loaded();
598 - // R_cap = 12 · 580 + 50000 + 5000 = 61960
599 - let r_cap = match a.get("derived.R_cap").unwrap() {
675 + let get_f = |k: &str| match a.get(k).unwrap() {
600 676 LookupValue::Float(x) => *x,
601 - v => panic!("expected float, got {v:?}"),
677 + v => panic!("expected float at {k}, got {v:?}"),
602 678 };
603 - assert!((r_cap - 61960.0).abs() < 1e-6, "R_cap = {r_cap}");
604 -
605 - // ARPU_standard = 0.4·16 + 0.3·24 + 0.2·36 + 0.1·60 = 26.80
606 - let arpu = match a.get("derived.ARPU_standard").unwrap() {
679 + let get_i = |k: &str| match a.get(k).unwrap() {
680 + LookupValue::Int(n) => *n as f64,
607 681 LookupValue::Float(x) => *x,
608 - v => panic!("{v:?}"),
682 + v => panic!("expected number at {k}, got {v:?}"),
609 683 };
610 - assert!((arpu - 26.80).abs() < 1e-9, "ARPU_standard = {arpu}");
611 684
612 - // stripe_fee_basic_std = 0.029 · 16 + 0.30 = 0.764
613 - let fee = match a.get("derived.stripe_fee_basic_std").unwrap() {
614 - LookupValue::Float(x) => *x,
615 - v => panic!("{v:?}"),
616 - };
617 - assert!((fee - 0.764).abs() < 1e-9, "stripe_fee_basic_std = {fee}");
685 + // R_cap = T_fixed_months · F_monthly + S_legal + S_shock — derived
686 + // from the same toml the test loads, so a price/reserve edit doesn't
687 + // force this test to be rewritten.
688 + let f_monthly = get_i("expenses.F_monthly");
689 + let t_fixed = get_i("reserve.T_fixed_months");
690 + let s_legal = get_i("reserve.S_legal");
691 + let s_shock = get_i("reserve.S_shock");
692 + let r_cap = get_f("derived.R_cap");
693 + let expected_r_cap = t_fixed * f_monthly + s_legal + s_shock;
694 + assert!((r_cap - expected_r_cap).abs() < 1e-6, "R_cap {r_cap} != {expected_r_cap}");
695 +
696 + // ARPU_standard = Σ (mix.<tier>_pct × standard.<tier>).
697 + let mix_basic = get_f("tier_mix.assumed.basic_pct");
698 + let mix_small = get_f("tier_mix.assumed.small_files_pct");
699 + let mix_big = get_f("tier_mix.assumed.big_files_pct");
700 + let mix_ev = get_f("tier_mix.assumed.everything_pct");
701 + let expected_arpu = mix_basic * get_i("tiers.standard.basic")
702 + + mix_small * get_i("tiers.standard.small_files")
703 + + mix_big * get_i("tiers.standard.big_files")
704 + + mix_ev * get_i("tiers.standard.everything");
705 + let arpu = get_f("derived.ARPU_standard");
706 + assert!((arpu - expected_arpu).abs() < 1e-9, "ARPU {arpu} != {expected_arpu}");
707 +
708 + // stripe_fee_basic_std = stripe.percent × price + stripe.fixed.
709 + let expected_fee = get_f("stripe.percent") * get_i("tiers.standard.basic")
710 + + get_f("stripe.fixed");
711 + let fee = get_f("derived.stripe_fee_basic_std");
712 + assert!((fee - expected_fee).abs() < 1e-9, "stripe_fee_basic_std {fee} != {expected_fee}");
618 713 }
619 714
620 715 #[test]
@@ -650,48 +745,75 @@ mod tests {
650 745 v => panic!("expected float at {k}, got {v:?}"),
651 746 };
652 747
653 - // Storage: weighted GB × $/GB/mo. 5.64 GB × 0.0065 = 0.03666
654 - let storage = get_f("derived.marginal_storage");
655 - assert!((storage - 0.03666).abs() < 1e-4, "storage = {storage}");
748 + let get_i = |k: &str| match a.get(k).unwrap() {
749 + LookupValue::Int(n) => *n as f64,
750 + LookupValue::Float(x) => *x,
751 + v => panic!("expected number at {k}, got {v:?}"),
752 + };
656 753
657 - // Stripe fee on creator subs at standard rates (weighted by tier mix):
658 - // 0.4·0.764 + 0.3·0.996 + 0.2·1.344 + 0.1·2.040 = 1.0772
754 + // Structural: marginal_avg_standard = storage + stripe + chargeback.
755 + // The individual sub-lines are computed from the toml, so verifying
756 + // the sum invariant is the useful assertion — pinning literal values
757 + // would just re-encode a Refined-A tier mix.
758 + let storage = get_f("derived.marginal_storage");
659 759 let stripe = get_f("derived.marginal_stripe_standard");
660 - assert!((stripe - 1.0772).abs() < 1e-4, "stripe = {stripe}");
661 -
662 - // Chargeback EV: 0.001 × $15 dispute = $0.015
663 760 let chargeback = get_f("derived.marginal_chargeback");
664 - assert!((chargeback - 0.015).abs() < 1e-9, "chargeback = {chargeback}");
665 -
666 - // marginal_avg_standard = sum
667 761 let avg = get_f("derived.marginal_avg_standard");
668 762 assert!((avg - (storage + stripe + chargeback)).abs() < 1e-9, "avg = {avg}");
669 763
670 - // Founding Stripe fee is lower (lower tier prices, less % component):
671 - // 0.4·0.532 + 0.3·0.648 + 0.2·0.822 + 0.1·1.170 = 0.6886
764 + // Chargeback formula: rate × dispute_fee. Pinned by the two inputs.
765 + let expected_chargeback = get_f("creator_marginal.chargeback_rate_tier_subs")
766 + * get_f("stripe.dispute_fee");
767 + assert!(
768 + (chargeback - expected_chargeback).abs() < 1e-9,
769 + "chargeback {chargeback} != rate × dispute {expected_chargeback}"
770 + );
771 +
772 + // Founding Stripe fee ≤ standard (lower prices → lower % component).
672 773 let stripe_f = get_f("derived.marginal_stripe_founding");
673 - assert!((stripe_f - 0.6886).abs() < 1e-4, "stripe_f = {stripe_f}");
774 + assert!(
775 + stripe_f <= stripe,
776 + "founding stripe {stripe_f} should be ≤ standard {stripe}"
777 + );
778 +
779 + // Storage weighted by the assumed tier mix (all inputs from toml).
780 + let expected_storage = (get_f("tier_mix.assumed.basic_pct") * get_f("creator_marginal.storage_basic_gb")
781 + + get_f("tier_mix.assumed.small_files_pct") * get_i("creator_marginal.storage_small_files_gb")
782 + + get_f("tier_mix.assumed.big_files_pct") * get_i("creator_marginal.storage_big_files_gb")
783 + + get_f("tier_mix.assumed.everything_pct") * get_i("creator_marginal.storage_everything_gb"))
784 + * get_f("creator_marginal.storage_cost_per_gb_per_month");
785 + assert!(
786 + (storage - expected_storage).abs() < 1e-9,
787 + "storage {storage} != {expected_storage}"
788 + );
674 789 }
675 790
676 791 #[test]
677 - fn derived_annual_prices_match_published_values() {
792 + fn derived_annual_prices_match_monthly_times_discount() {
793 + // Formula: monthly × 12 × annual_discount.multiplier, rounded to
794 + // the nearest whole dollar. All four tiers × two rate classes.
678 795 let a = loaded();
679 796 let get_f = |k: &str| match a.get(k).unwrap() {
680 797 LookupValue::Float(x) => *x,
681 798 v => panic!("expected float at {k}, got {v:?}"),
682 799 };
683 -
684 - // Founder: $8/$12/$18/$30 × 12 × 0.9 → rounded.
685 - assert_eq!(get_f("derived.annual_founding_basic"), 86.0);
686 - assert_eq!(get_f("derived.annual_founding_small_files"), 130.0);
687 - assert_eq!(get_f("derived.annual_founding_big_files"), 194.0);
688 - assert_eq!(get_f("derived.annual_founding_everything"), 324.0);
689 -
690 - // Standard: $16/$24/$36/$60 × 12 × 0.9 → rounded.
691 - assert_eq!(get_f("derived.annual_standard_basic"), 173.0);
692 - assert_eq!(get_f("derived.annual_standard_small_files"), 259.0);
693 - assert_eq!(get_f("derived.annual_standard_big_files"), 389.0);
694 - assert_eq!(get_f("derived.annual_standard_everything"), 648.0);
800 + let get_i = |k: &str| match a.get(k).unwrap() {
801 + LookupValue::Int(n) => *n as f64,
802 + LookupValue::Float(x) => *x,
803 + v => panic!("expected number at {k}, got {v:?}"),
804 + };
805 + let mult = get_f("annual_discount.multiplier");
806 + for tier in ["basic", "small_files", "big_files", "everything"] {
807 + for class in ["founding", "standard"] {
808 + let monthly = get_i(&format!("tiers.{class}.{tier}"));
809 + let expected = (monthly * 12.0 * mult).round();
810 + let actual = get_f(&format!("derived.annual_{class}_{tier}"));
811 + assert_eq!(
812 + actual, expected,
813 + "annual_{class}_{tier}: {actual} != round({monthly} × 12 × {mult}) = {expected}"
814 + );
815 + }
816 + }
695 817 }
696 818
697 819 #[test]
@@ -700,9 +822,17 @@ mod tests {
700 822 let out = a
701 823 .substitute("Break-even at ~{{ derived.break_even_standard | ceil }} creators.")
702 824 .unwrap();
703 - // break_even_standard ≈ 22.6 → ceil → 23 (with full marginal model at
704 - // standard $16/$24/$36/$60: 580 / (26.80 − 1.128) ≈ 22.6).
705 - assert_eq!(out, "Break-even at ~23 creators.");
825 + // The numeric value drifts with any pricing change, so pin the *shape*
826 + // (integer followed by "creators.") rather than a specific N.
827 + assert!(
828 + out.starts_with("Break-even at ~") && out.ends_with(" creators."),
829 + "unexpected shape: {out:?}"
830 + );
831 + let n_str = out
832 + .trim_start_matches("Break-even at ~")
833 + .trim_end_matches(" creators.");
834 + let n: i64 = n_str.parse().expect("ceil should render as an integer");
835 + assert!(n > 0, "break-even should be positive, got {n}");
706 836 }
707 837
708 838 #[test]
@@ -725,8 +855,11 @@ mod tests {
725 855 let out = a
726 856 .substitute("{{ derived.break_even_standard | round(1) }}")
727 857 .unwrap();
728 - // 580 / (26.80 − ~1.128) ≈ 22.6 at canonical $16/$24/$36/$60.
729 - assert_eq!(out, "22.6");
858 + // round(1) produces a single-decimal string. Verify the shape
859 + // rather than a specific value (which changes when prices change).
860 + let dot = out.find('.').expect("round(1) should include a decimal point");
861 + assert_eq!(out.len() - dot - 1, 1, "should have exactly one decimal digit, got {out:?}");
862 + let _: f64 = out.parse().expect("should parse as float");
730 863 }
731 864
732 865 #[test]
@@ -828,9 +961,15 @@ mod tests {
828 961
829 962 #[test]
830 963 fn validation_catches_founding_above_standard() {
831 - // `basic = 8` appears only in [tiers.founding] in the canonical fixture
832 - // (standard has `basic = 16`), so this substring is unambiguous.
833 - let t = FIXTURE.replace("basic = 8", "basic = 999");
964 + // Rewrite `[tiers.founding]` so basic > standard.basic. Using a
965 + // section-anchored search keeps this test working regardless of the
966 + // specific dollar figure the standard tier is set to.
967 + let re = regex_lite::Regex::new(r"(?m)^\[tiers\.founding\]\n((?:.*\n)*?)basic = \d+")
968 + .unwrap();
969 + let t = re.replace(FIXTURE, |caps: &regex_lite::Captures| {
970 + format!("[tiers.founding]\n{}basic = 99999", &caps[1])
971 + }).into_owned();
972 + assert!(t != FIXTURE, "regex must have matched");
834 973 let a = Assumptions::parse(&t).unwrap();
835 974 let err = a.validate().unwrap_err();
836 975 match err {
@@ -842,6 +981,57 @@ mod tests {
842 981 }
843 982
844 983 #[test]
984 + fn parse_size_bytes_binary_units() {
985 + // No space, case-insensitive suffix, binary units.
986 + assert_eq!(parse_size_bytes("10MB").unwrap(), 10 * 1024 * 1024);
987 + assert_eq!(parse_size_bytes("50GB").unwrap(), 50 * 1024 * 1024 * 1024);
988 + assert_eq!(parse_size_bytes("500MB").unwrap(), 500 * 1024 * 1024);
989 + assert_eq!(parse_size_bytes("250GB").unwrap(), 250i64 * 1024 * 1024 * 1024);
990 + assert_eq!(parse_size_bytes("500GB").unwrap(), 500i64 * 1024 * 1024 * 1024);
991 + assert_eq!(parse_size_bytes("20GB").unwrap(), 20i64 * 1024 * 1024 * 1024);
992 + assert_eq!(parse_size_bytes("1KB").unwrap(), 1024);
993 + assert_eq!(parse_size_bytes("1B").unwrap(), 1);
994 + // Case-insensitive.
995 + assert_eq!(parse_size_bytes("10mb").unwrap(), 10 * 1024 * 1024);
996 + // Space between number and unit is tolerated (parser trims), even
997 + // though the [tier_limits] convention writes "10MB" with no space.
998 + assert_eq!(parse_size_bytes("10 MB").unwrap(), 10 * 1024 * 1024);
999 + // Rejects malformed input.
1000 + assert!(parse_size_bytes("10ZB").is_err(), "unknown unit");
1001 + assert!(parse_size_bytes("MB").is_err(), "missing number");
1002 + }
1003 +
1004 + #[test]
1005 + fn validate_catches_tier_bytes_display_drift() {
1006 + // Rewrite `basic_per_file` from its current display value to "999GB"
1007 + // while leaving `tier_bytes.basic_per_file` alone — validator should
1008 + // catch the mismatch. Using a fresh regex is more robust than
1009 + // hardcoding whatever the current display string happens to be.
1010 + let re = regex_lite::Regex::new(r#"basic_per_file = "[^"]+""#).unwrap();
1011 + let broken = re.replace(FIXTURE, r#"basic_per_file = "999GB""#).into_owned();
1012 + assert!(broken != FIXTURE, "regex must have matched something");
1013 + let a = Assumptions::parse(&broken).unwrap();
1014 + let err = a.validate().unwrap_err();
1015 + match err {
1016 + AssumptionsError::Validation(rules) => {
1017 + assert!(
1018 + rules.iter().any(|r| r.contains("tier_limits.basic_per_file")
1019 + && r.contains("tier_bytes.basic_per_file")),
1020 + "expected drift failure, got: {rules:?}"
1021 + );
1022 + }
1023 + other => panic!("expected Validation, got {other:?}"),
1024 + }
1025 + }
1026 +
1027 + #[test]
1028 + fn validate_accepts_canonical_tier_bytes_pairing() {
1029 + let a = loaded();
1030 + // Guards that the canonical fixture's tier_bytes match tier_limits.
1031 + a.validate().expect("canonical fixture: tier_bytes must match tier_limits");
1032 + }
1033 +
1034 + #[test]
845 1035 fn validation_catches_surplus_split_off() {
846 1036 let t = FIXTURE.replace("surplus_split_reserve = 0.20", "surplus_split_reserve = 0.30");
847 1037 let a = Assumptions::parse(&t).unwrap();