max / makenotwork
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: ®ex_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(); |