Skip to main content

max / makenotwork

7.9 KB · 191 lines History Blame Raw
1 //! Tier prices and storage envelopes pulled from `assumptions.toml` at startup.
2 //!
3 //! Templates referencing these via `{{ tier_prices.basic_std }}` etc. stay in
4 //! sync with the docengine substitution system — both read from the same toml.
5 //! A price change is a one-line edit to assumptions.toml + a server restart.
6 //!
7 //! Missing or wrong-typed keys panic at startup (same pattern as
8 //! `Assumptions::validate` failure in main.rs). Production never serves with a
9 //! half-loaded `TierPrices`.
10
11 use docengine::{Assumptions, LookupValue};
12
13 #[derive(Clone, Debug)]
14 pub struct TierPrices {
15 // Standard monthly (post-founder sticker rates).
16 pub basic_std: i32,
17 pub small_files_std: i32,
18 pub big_files_std: i32,
19 pub everything_std: i32,
20 // Founder monthly (50% of standard, locked for life when window closes).
21 pub basic_founder: i32,
22 pub small_files_founder: i32,
23 pub big_files_founder: i32,
24 pub everything_founder: i32,
25 // Standard annual (monthly × 12 × annual_discount.multiplier, rounded).
26 pub annual_basic_std: i32,
27 pub annual_small_files_std: i32,
28 pub annual_big_files_std: i32,
29 pub annual_everything_std: i32,
30 // Founder annual.
31 pub annual_basic_founder: i32,
32 pub annual_small_files_founder: i32,
33 pub annual_big_files_founder: i32,
34 pub annual_everything_founder: i32,
35 // Per-file caps and total storage caps, as display strings ("10MB", "50GB").
36 pub basic_per_file: String,
37 pub small_files_per_file: String,
38 pub big_files_per_file: String,
39 pub everything_per_file: String,
40 pub basic_total: String,
41 pub small_files_total: String,
42 pub big_files_total: String,
43 pub everything_total: String,
44 // Founder cohort cap, display string with thousands separator ("1,000").
45 pub cohort_cap_display: String,
46 }
47
48 impl TierPrices {
49 pub fn from_assumptions(a: &Assumptions) -> Self {
50 Self {
51 basic_std: int_at(a, "tiers.standard.basic"),
52 small_files_std: int_at(a, "tiers.standard.small_files"),
53 big_files_std: int_at(a, "tiers.standard.big_files"),
54 everything_std: int_at(a, "tiers.standard.everything"),
55 basic_founder: int_at(a, "tiers.founding.basic"),
56 small_files_founder: int_at(a, "tiers.founding.small_files"),
57 big_files_founder: int_at(a, "tiers.founding.big_files"),
58 everything_founder: int_at(a, "tiers.founding.everything"),
59 annual_basic_std: int_at(a, "derived.annual_standard_basic"),
60 annual_small_files_std: int_at(a, "derived.annual_standard_small_files"),
61 annual_big_files_std: int_at(a, "derived.annual_standard_big_files"),
62 annual_everything_std: int_at(a, "derived.annual_standard_everything"),
63 annual_basic_founder: int_at(a, "derived.annual_founding_basic"),
64 annual_small_files_founder: int_at(a, "derived.annual_founding_small_files"),
65 annual_big_files_founder: int_at(a, "derived.annual_founding_big_files"),
66 annual_everything_founder: int_at(a, "derived.annual_founding_everything"),
67 basic_per_file: str_at(a, "tier_limits.basic_per_file"),
68 small_files_per_file: str_at(a, "tier_limits.small_files_per_file"),
69 big_files_per_file: str_at(a, "tier_limits.big_files_per_file"),
70 everything_per_file: str_at(a, "tier_limits.everything_per_file"),
71 basic_total: str_at(a, "tier_limits.basic_total"),
72 small_files_total: str_at(a, "tier_limits.small_files_total"),
73 big_files_total: str_at(a, "tier_limits.big_files_total"),
74 everything_total: str_at(a, "tier_limits.everything_total"),
75 cohort_cap_display: str_at(a, "cohort.cap_display"),
76 }
77 }
78 }
79
80 /// Display row for the dashboard tier-picker grid (`user_creator.html`).
81 #[derive(Clone, Debug)]
82 pub struct TierCard {
83 pub key: &'static str,
84 pub label: &'static str,
85 pub storage: String,
86 pub founder_monthly: i32,
87 pub standard_monthly: i32,
88 pub founder_annual: i32,
89 pub standard_annual: i32,
90 }
91
92 impl TierPrices {
93 /// Build the four tier cards the dashboard renders. Order matters
94 /// (Basic, Small Files, Big Files, Everything) — it's the canonical
95 /// presentation order.
96 pub fn cards(&self) -> Vec<TierCard> {
97 vec![
98 TierCard {
99 key: "basic",
100 label: "Basic",
101 storage: format!("{}, {}/file", self.basic_total, self.basic_per_file),
102 founder_monthly: self.basic_founder,
103 standard_monthly: self.basic_std,
104 founder_annual: self.annual_basic_founder,
105 standard_annual: self.annual_basic_std,
106 },
107 TierCard {
108 key: "small_files",
109 label: "Small Files",
110 storage: format!("{}, {}/file", self.small_files_total, self.small_files_per_file),
111 founder_monthly: self.small_files_founder,
112 standard_monthly: self.small_files_std,
113 founder_annual: self.annual_small_files_founder,
114 standard_annual: self.annual_small_files_std,
115 },
116 TierCard {
117 key: "big_files",
118 label: "Big Files",
119 storage: format!("{}, {}/file", self.big_files_total, self.big_files_per_file),
120 founder_monthly: self.big_files_founder,
121 standard_monthly: self.big_files_std,
122 founder_annual: self.annual_big_files_founder,
123 standard_annual: self.annual_big_files_std,
124 },
125 TierCard {
126 key: "everything",
127 label: "Everything",
128 storage: format!(
129 "{}, {}/file, all features",
130 self.everything_total, self.everything_per_file
131 ),
132 founder_monthly: self.everything_founder,
133 standard_monthly: self.everything_std,
134 founder_annual: self.annual_everything_founder,
135 standard_annual: self.annual_everything_std,
136 },
137 ]
138 }
139 }
140
141 fn int_at(a: &Assumptions, key: &str) -> i32 {
142 match a.get(key) {
143 Some(LookupValue::Int(n)) => i32::try_from(*n)
144 .unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32")),
145 Some(LookupValue::Float(x)) => x.round() as i32,
146 other => panic!("expected integer at {key}, got {other:?}"),
147 }
148 }
149
150 fn str_at(a: &Assumptions, key: &str) -> String {
151 match a.get(key) {
152 Some(LookupValue::String(s)) => s.clone(),
153 other => panic!("expected string at {key}, got {other:?}"),
154 }
155 }
156
157 #[cfg(test)]
158 mod tests {
159 use super::*;
160
161 const ASSUMPTIONS_PATH: &str =
162 "../../_private/docs/mnw/server-internal/business/assumptions.toml";
163
164 /// Guards every key TierPrices reads. If a future toml edit removes one of
165 /// these or flips its type, the panic in `from_assumptions` will fire at
166 /// startup; this test catches it at PR time instead.
167 #[test]
168 fn from_canonical_assumptions_populates_every_field() {
169 let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml");
170 let p = TierPrices::from_assumptions(&a);
171
172 // Sanity-check sentinels (values match the toml; if they change in
173 // toml, update here too).
174 assert_eq!(p.basic_std, 16);
175 assert_eq!(p.everything_std, 60);
176 assert_eq!(p.basic_founder, 8);
177 assert_eq!(p.annual_basic_std, 173);
178 assert_eq!(p.annual_everything_founder, 324);
179 assert_eq!(p.basic_per_file, "10MB");
180 assert_eq!(p.everything_total, "500GB");
181 assert_eq!(p.cohort_cap_display, "1,000");
182
183 // Cards iteration produces the four canonical rows.
184 let cards = p.cards();
185 assert_eq!(cards.len(), 4);
186 assert_eq!(cards[0].key, "basic");
187 assert_eq!(cards[3].key, "everything");
188 assert_eq!(cards[1].standard_monthly, 24);
189 }
190 }
191