| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
use docengine::{Assumptions, LookupValue}; |
| 12 |
|
| 13 |
#[derive(Clone, Debug, Default)] |
| 14 |
pub struct TierPrices { |
| 15 |
|
| 16 |
pub basic_std: i32, |
| 17 |
pub small_files_std: i32, |
| 18 |
pub big_files_std: i32, |
| 19 |
pub everything_std: i32, |
| 20 |
|
| 21 |
pub basic_founder: i32, |
| 22 |
pub small_files_founder: i32, |
| 23 |
pub big_files_founder: i32, |
| 24 |
pub everything_founder: i32, |
| 25 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 94 |
|
| 95 |
|
| 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 |
|
| 142 |
|
| 143 |
|
| 144 |
#[derive(Clone, Debug)] |
| 145 |
pub struct CostSegment { |
| 146 |
|
| 147 |
|
| 148 |
pub kind: &'static str, |
| 149 |
|
| 150 |
pub label: &'static str, |
| 151 |
|
| 152 |
pub amount: String, |
| 153 |
|
| 154 |
pub cents: i32, |
| 155 |
|
| 156 |
|
| 157 |
pub tooltip: String, |
| 158 |
} |
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
#[derive(Clone, Debug)] |
| 164 |
pub struct CostAllocationRow { |
| 165 |
pub tier_key: &'static str, |
| 166 |
pub tier_label: &'static str, |
| 167 |
|
| 168 |
pub tier_price: i32, |
| 169 |
pub segments: Vec<CostSegment>, |
| 170 |
|
| 171 |
|
| 172 |
pub aria_label: String, |
| 173 |
} |
| 174 |
|
| 175 |
|
| 176 |
#[derive(Clone, Debug, Default)] |
| 177 |
pub struct CostAllocation { |
| 178 |
pub rows: Vec<CostAllocationRow>, |
| 179 |
} |
| 180 |
|
| 181 |
impl CostAllocation { |
| 182 |
pub fn from_assumptions(a: &Assumptions, tp: &TierPrices) -> Self { |
| 183 |
let rows = vec![ |
| 184 |
row(a, "basic", "Basic", tp.basic_std), |
| 185 |
row(a, "small_files", "Small Files", tp.small_files_std), |
| 186 |
row(a, "big_files", "Big Files", tp.big_files_std), |
| 187 |
row(a, "everything", "Everything", tp.everything_std), |
| 188 |
]; |
| 189 |
Self { rows } |
| 190 |
} |
| 191 |
} |
| 192 |
|
| 193 |
fn row(a: &Assumptions, key: &'static str, label: &'static str, price: i32) -> CostAllocationRow { |
| 194 |
let segments = vec![ |
| 195 |
seg(a, key, "stripe", "Stripe processing", |
| 196 |
"Stripe processing: {amount}. Stripe's fee on your monthly subscription."), |
| 197 |
seg(a, key, "storage", "Storage", |
| 198 |
"Storage: {amount}. At-rest storage, bandwidth, virus scanning, and \ |
| 199 |
backups at your tier's typical fill, sized to absorb spikes and \ |
| 200 |
provider price changes without a price increase to you."), |
| 201 |
seg(a, key, "support", "Human support time", |
| 202 |
"Human support time: {amount}. Identity recovery, billing disputes, \ |
| 203 |
moderation, abuse, and legal — the work that can't be automated. \ |
| 204 |
Per-creator floor; the same at every tier."), |
| 205 |
seg(a, key, "engineering", "Product engineering", |
| 206 |
"Product engineering: {amount}. Bug fixes and ongoing product work. \ |
| 207 |
Per-creator floor; the same at every tier."), |
| 208 |
seg(a, key, "reserves", "Reserves", |
| 209 |
"Reserves: {amount}. Held against a bad month — refunds, chargebacks, \ |
| 210 |
unexpected costs — so a single incident doesn't force a price change \ |
| 211 |
or shutdown."), |
| 212 |
seg(a, key, "earnback", "Earn-back (returned to creators)", |
| 213 |
"Earn-back: {amount}. The margin above cost, reinvested to build the \ |
| 214 |
platform out and lower prices as we grow, with the rest returned to \ |
| 215 |
creators as earn-back credit (launching by 2027-01-01). It's how a \ |
| 216 |
fair platform gets big and stays that way."), |
| 217 |
]; |
| 218 |
let aria_label = build_aria_label(label, price, &segments); |
| 219 |
CostAllocationRow { |
| 220 |
tier_key: key, |
| 221 |
tier_label: label, |
| 222 |
tier_price: price, |
| 223 |
segments, |
| 224 |
aria_label, |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
fn seg( |
| 229 |
a: &Assumptions, |
| 230 |
tier_key: &str, |
| 231 |
seg_kind: &'static str, |
| 232 |
seg_label: &'static str, |
| 233 |
tooltip_template: &str, |
| 234 |
) -> CostSegment { |
| 235 |
let dollars = float_at(a, &format!("cost_allocation.{tier_key}.{seg_kind}")); |
| 236 |
let cents = (dollars * 100.0).round() as i32; |
| 237 |
let amount = format!("${dollars:.2}"); |
| 238 |
let tooltip = tooltip_template.replace("{amount}", &amount); |
| 239 |
CostSegment { |
| 240 |
kind: seg_kind, |
| 241 |
label: seg_label, |
| 242 |
amount, |
| 243 |
cents, |
| 244 |
tooltip, |
| 245 |
} |
| 246 |
} |
| 247 |
|
| 248 |
fn build_aria_label(tier_label: &str, price: i32, segments: &[CostSegment]) -> String { |
| 249 |
let mut s = format!("{tier_label} tier ${price}/mo allocation: "); |
| 250 |
for (i, seg) in segments.iter().enumerate() { |
| 251 |
if i > 0 { s.push_str(", "); } |
| 252 |
s.push_str(seg.label); |
| 253 |
s.push(' '); |
| 254 |
s.push_str(&seg.amount); |
| 255 |
} |
| 256 |
s |
| 257 |
} |
| 258 |
|
| 259 |
|
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 |
|
| 266 |
|
| 267 |
|
| 268 |
|
| 269 |
|
| 270 |
|
| 271 |
#[derive(Clone, Debug, Default)] |
| 272 |
pub struct RunwayConfig { |
| 273 |
pub quarters: i32, |
| 274 |
pub last_updated_iso: String, |
| 275 |
} |
| 276 |
|
| 277 |
impl RunwayConfig { |
| 278 |
pub fn from_assumptions(a: &Assumptions) -> Self { |
| 279 |
Self { |
| 280 |
quarters: int_at(a, "runway.quarters"), |
| 281 |
last_updated_iso: str_at(a, "runway.last_updated_iso"), |
| 282 |
} |
| 283 |
} |
| 284 |
|
| 285 |
|
| 286 |
pub fn is_published(&self) -> bool { |
| 287 |
self.quarters > 0 |
| 288 |
} |
| 289 |
} |
| 290 |
|
| 291 |
fn float_at(a: &Assumptions, key: &str) -> f64 { |
| 292 |
match a.get(key) { |
| 293 |
Some(LookupValue::Float(x)) => *x, |
| 294 |
Some(LookupValue::Int(n)) => *n as f64, |
| 295 |
other => panic!("expected number at {key}, got {other:?}"), |
| 296 |
} |
| 297 |
} |
| 298 |
|
| 299 |
fn int_at(a: &Assumptions, key: &str) -> i32 { |
| 300 |
match a.get(key) { |
| 301 |
Some(LookupValue::Int(n)) => i32::try_from(*n) |
| 302 |
.unwrap_or_else(|_| panic!("{key} = {n} does not fit in i32")), |
| 303 |
Some(LookupValue::Float(x)) => x.round() as i32, |
| 304 |
other => panic!("expected integer at {key}, got {other:?}"), |
| 305 |
} |
| 306 |
} |
| 307 |
|
| 308 |
fn str_at(a: &Assumptions, key: &str) -> String { |
| 309 |
match a.get(key) { |
| 310 |
Some(LookupValue::String(s)) => s.clone(), |
| 311 |
other => panic!("expected string at {key}, got {other:?}"), |
| 312 |
} |
| 313 |
} |
| 314 |
|
| 315 |
#[cfg(test)] |
| 316 |
mod tests { |
| 317 |
use super::*; |
| 318 |
|
| 319 |
const ASSUMPTIONS_PATH: &str = "docs/business/assumptions.toml"; |
| 320 |
|
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
#[test] |
| 325 |
fn cost_allocation_from_canonical_assumptions_sums_to_tier_price() { |
| 326 |
|
| 327 |
|
| 328 |
|
| 329 |
|
| 330 |
|
| 331 |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); |
| 332 |
let tp = TierPrices::from_assumptions(&a); |
| 333 |
let alloc = CostAllocation::from_assumptions(&a, &tp); |
| 334 |
assert_eq!(alloc.rows.len(), 4); |
| 335 |
|
| 336 |
let prices = [tp.basic_std, tp.small_files_std, tp.big_files_std, tp.everything_std]; |
| 337 |
for (row, &price) in alloc.rows.iter().zip(prices.iter()) { |
| 338 |
assert_eq!(row.tier_price, price); |
| 339 |
let sum_cents: i32 = row.segments.iter().map(|s| s.cents).sum(); |
| 340 |
assert_eq!( |
| 341 |
sum_cents, |
| 342 |
price * 100, |
| 343 |
"{tier} segments sum to {sum_cents}¢ but tier price is ${price} = {expected}¢", |
| 344 |
tier = row.tier_label, |
| 345 |
expected = price * 100, |
| 346 |
); |
| 347 |
|
| 348 |
let kinds: Vec<&str> = row.segments.iter().map(|s| s.kind).collect(); |
| 349 |
assert_eq!( |
| 350 |
kinds, |
| 351 |
vec!["stripe", "storage", "support", "engineering", "reserves", "earnback"], |
| 352 |
); |
| 353 |
} |
| 354 |
|
| 355 |
|
| 356 |
|
| 357 |
|
| 358 |
let basic = &alloc.rows[0]; |
| 359 |
assert_eq!(basic.segments[0].amount, "$0.76"); |
| 360 |
assert_eq!(basic.segments[5].amount, "$1.84"); |
| 361 |
let everything = &alloc.rows[3]; |
| 362 |
assert_eq!(everything.segments[2].amount, "$5.00"); |
| 363 |
assert_eq!(everything.segments[3].amount, "$6.00"); |
| 364 |
assert_eq!(everything.segments[4].amount, "$7.50"); |
| 365 |
} |
| 366 |
|
| 367 |
#[test] |
| 368 |
fn runway_config_loads_from_canonical_assumptions() { |
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); |
| 374 |
let r = RunwayConfig::from_assumptions(&a); |
| 375 |
assert!(r.quarters >= 0, "quarters must be a non-negative integer"); |
| 376 |
assert!(!r.last_updated_iso.is_empty(), "last_updated_iso must be set"); |
| 377 |
|
| 378 |
assert_eq!(r.last_updated_iso.len(), 10); |
| 379 |
assert!(r.last_updated_iso.chars().nth(4) == Some('-')); |
| 380 |
assert!(r.last_updated_iso.chars().nth(7) == Some('-')); |
| 381 |
} |
| 382 |
|
| 383 |
#[test] |
| 384 |
fn runway_config_is_published_only_when_quarters_nonzero() { |
| 385 |
|
| 386 |
|
| 387 |
|
| 388 |
|
| 389 |
let r = RunwayConfig { quarters: 0, last_updated_iso: "2026-06-03".into() }; |
| 390 |
assert!(!r.is_published()); |
| 391 |
let r = RunwayConfig { quarters: 4, last_updated_iso: "2026-06-03".into() }; |
| 392 |
assert!(r.is_published()); |
| 393 |
} |
| 394 |
|
| 395 |
#[test] |
| 396 |
fn cost_allocation_aria_label_names_every_segment() { |
| 397 |
|
| 398 |
|
| 399 |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); |
| 400 |
let tp = TierPrices::from_assumptions(&a); |
| 401 |
let alloc = CostAllocation::from_assumptions(&a, &tp); |
| 402 |
let aria = &alloc.rows[0].aria_label; |
| 403 |
assert!(aria.starts_with("Basic tier $16/mo allocation: ")); |
| 404 |
for label in ["Stripe processing", "Storage", "Human support time", |
| 405 |
"Product engineering", "Reserves", "Earn-back (returned to creators)"] { |
| 406 |
assert!(aria.contains(label), "aria-label missing {label}: {aria}"); |
| 407 |
} |
| 408 |
} |
| 409 |
|
| 410 |
#[test] |
| 411 |
fn from_canonical_assumptions_populates_every_field() { |
| 412 |
let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); |
| 413 |
let p = TierPrices::from_assumptions(&a); |
| 414 |
|
| 415 |
|
| 416 |
|
| 417 |
assert_eq!(p.basic_std, 16); |
| 418 |
assert_eq!(p.everything_std, 60); |
| 419 |
assert_eq!(p.basic_founder, 8); |
| 420 |
assert_eq!(p.annual_basic_std, 173); |
| 421 |
assert_eq!(p.annual_everything_founder, 324); |
| 422 |
assert_eq!(p.basic_per_file, "10MB"); |
| 423 |
assert_eq!(p.everything_total, "500GB"); |
| 424 |
assert_eq!(p.cohort_cap_display, "1,000"); |
| 425 |
|
| 426 |
|
| 427 |
let cards = p.cards(); |
| 428 |
assert_eq!(cards.len(), 4); |
| 429 |
assert_eq!(cards[0].key, "basic"); |
| 430 |
assert_eq!(cards[3].key, "everything"); |
| 431 |
assert_eq!(cards[1].standard_monthly, 24); |
| 432 |
} |
| 433 |
} |
| 434 |
|