max / makenotwork
11 files changed,
+288 insertions,
-56 deletions
| @@ -76,6 +76,7 @@ | |||
| 76 | 76 | pub docs: Arc<DocLoader>, | |
| 77 | 77 | pub tier_prices: tier_prices::TierPrices, | |
| 78 | 78 | pub cost_allocation: tier_prices::CostAllocation, | |
| 79 | + | pub runway_config: tier_prices::RunwayConfig, | |
| 79 | 80 | pub scanner: Option<Arc<ScanPipeline>>, | |
| 80 | 81 | pub webauthn: Arc<Webauthn>, | |
| 81 | 82 | pub syntax: Option<Arc<git::SyntaxHighlighter>>, |
| @@ -322,6 +322,7 @@ | |||
| 322 | 322 | let tp = makenotwork::tier_prices::TierPrices::from_assumptions(&assumptions); | |
| 323 | 323 | makenotwork::tier_prices::CostAllocation::from_assumptions(&assumptions, &tp) | |
| 324 | 324 | }, | |
| 325 | + | runway_config: makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions), | |
| 325 | 326 | scanner, | |
| 326 | 327 | webauthn, | |
| 327 | 328 | syntax, |
| @@ -255,6 +255,38 @@ | |||
| 255 | 255 | s | |
| 256 | 256 | } | |
| 257 | 257 | ||
| 258 | + | /// Operator-edited runway figures, loaded once at startup. The | |
| 259 | + | /// live paying-creator counts come from the DB at request time and | |
| 260 | + | /// are NOT in this struct — see `db::creator_tiers::count_active_paying` | |
| 261 | + | /// and `count_trialing_or_grace`. | |
| 262 | + | /// | |
| 263 | + | /// `quarters` is the cash-runway bucket in whole quarters (rounded down). | |
| 264 | + | /// A value of `0` means "not yet published" and the template should | |
| 265 | + | /// suppress the line rather than render "0 quarters". | |
| 266 | + | /// | |
| 267 | + | /// `last_updated_iso` is the date the operator last refreshed the figure, | |
| 268 | + | /// in ISO 8601 (`YYYY-MM-DD`). Rendered verbatim into the "Last updated" | |
| 269 | + | /// stamp on the disclosure surface. | |
| 270 | + | #[derive(Clone, Debug, Default)] | |
| 271 | + | pub struct RunwayConfig { | |
| 272 | + | pub quarters: i32, | |
| 273 | + | pub last_updated_iso: String, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | impl RunwayConfig { | |
| 277 | + | pub fn from_assumptions(a: &Assumptions) -> Self { | |
| 278 | + | Self { | |
| 279 | + | quarters: int_at(a, "runway.quarters"), | |
| 280 | + | last_updated_iso: str_at(a, "runway.last_updated_iso"), | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | /// True iff the operator has published a runway figure. Suppress the | |
| 284 | + | /// "X quarters at current burn" line when this is false. | |
| 285 | + | pub fn is_published(&self) -> bool { | |
| 286 | + | self.quarters > 0 | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 258 | 290 | fn float_at(a: &Assumptions, key: &str) -> f64 { | |
| 259 | 291 | match a.get(key) { | |
| 260 | 292 | Some(LookupValue::Float(x)) => *x, | |
| @@ -331,6 +363,34 @@ | |||
| 331 | 363 | assert_eq!(everything.segments[4].amount, "$7.50"); // reserves 12.5% of $60 | |
| 332 | 364 | } | |
| 333 | 365 | ||
| 366 | + | #[test] | |
| 367 | + | fn runway_config_loads_from_canonical_assumptions() { | |
| 368 | + | // The presence of the [runway] block is the only enforced thing — | |
| 369 | + | // the values inside are operator-edited. We pin the keys so a | |
| 370 | + | // future toml edit that renames `quarters` or `last_updated_iso` | |
| 371 | + | // is caught at PR time, not at boot. | |
| 372 | + | let a = Assumptions::load(ASSUMPTIONS_PATH).expect("load canonical toml"); | |
| 373 | + | let r = RunwayConfig::from_assumptions(&a); | |
| 374 | + | assert!(r.quarters >= 0, "quarters must be a non-negative integer"); | |
| 375 | + | assert!(!r.last_updated_iso.is_empty(), "last_updated_iso must be set"); | |
| 376 | + | // ISO 8601 date format: YYYY-MM-DD. | |
| 377 | + | assert_eq!(r.last_updated_iso.len(), 10); | |
| 378 | + | assert!(r.last_updated_iso.chars().nth(4) == Some('-')); | |
| 379 | + | assert!(r.last_updated_iso.chars().nth(7) == Some('-')); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | #[test] | |
| 383 | + | fn runway_config_is_published_only_when_quarters_nonzero() { | |
| 384 | + | // The disclosure template hides the cash-runway bullet when this | |
| 385 | + | // returns false, so a freshly-deployed instance with quarters=0 | |
| 386 | + | // doesn't display "0 quarters at current burn" — which would be | |
| 387 | + | // both wrong and alarming. | |
| 388 | + | let r = RunwayConfig { quarters: 0, last_updated_iso: "2026-06-03".into() }; | |
| 389 | + | assert!(!r.is_published()); | |
| 390 | + | let r = RunwayConfig { quarters: 4, last_updated_iso: "2026-06-03".into() }; | |
| 391 | + | assert!(r.is_published()); | |
| 392 | + | } | |
| 393 | + | ||
| 334 | 394 | #[test] | |
| 335 | 395 | fn cost_allocation_aria_label_names_every_segment() { | |
| 336 | 396 | // Screen readers get the full breakdown via aria-label since the |
| @@ -293,3 +293,19 @@ | |||
| 293 | 293 | engineering = 6.00 | |
| 294 | 294 | reserves = 7.50 | |
| 295 | 295 | earnback = 33.46 | |
| 296 | + | ||
| 297 | + | ||
| 298 | + | # ─── Runway disclosure (transparency block on /docs/about/economics) ───── | |
| 299 | + | # | |
| 300 | + | # Operator-set. Refreshed quarterly at sprint close, alongside the | |
| 301 | + | # /changelog recap. The paying-creator count on the page is pulled live | |
| 302 | + | # from the DB; this number is the cash-runway bucket. | |
| 303 | + | # | |
| 304 | + | # Units: quarters at current burn (whole-number bucket; we round down | |
| 305 | + | # rather than mislead in either direction). | |
| 306 | + | # | |
| 307 | + | # A value of 0 means "not yet published" — the page renders without a | |
| 308 | + | # number until the first quarterly refresh sets it. | |
| 309 | + | [runway] | |
| 310 | + | quarters = 0 | |
| 311 | + | last_updated_iso = "2026-06-03" |
| @@ -472,6 +472,47 @@ | |||
| 472 | 472 | Ok(()) | |
| 473 | 473 | } | |
| 474 | 474 | ||
| 475 | + | /// Count fully-paying creators — `status = 'active'` only. | |
| 476 | + | /// | |
| 477 | + | /// Excludes trialing (free trial), past_due (payment failed but not yet | |
| 478 | + | /// canceled), canceled-in-grace (winding down), and incomplete states. | |
| 479 | + | /// This is the number that goes on the runway disclosure as "paying | |
| 480 | + | /// creators today": revenue-bearing seats, no fudge. | |
| 481 | + | #[tracing::instrument(skip_all)] | |
| 482 | + | pub async fn count_active_paying(pool: &PgPool) -> Result<i64> { | |
| 483 | + | let count: (i64,) = sqlx::query_as( | |
| 484 | + | "SELECT COUNT(*) FROM creator_subscriptions WHERE status = 'active'", | |
| 485 | + | ) | |
| 486 | + | .fetch_one(pool) | |
| 487 | + | .await?; | |
| 488 | + | Ok(count.0) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | /// Count creators in a trial or 30-day cancellation grace period. | |
| 492 | + | /// | |
| 493 | + | /// These are not revenue-bearing today but represent the near-term | |
| 494 | + | /// pipeline: trialing seats may convert, grace seats may resubscribe | |
| 495 | + | /// before enforcement. Disclosed as a secondary number on the runway | |
| 496 | + | /// surface so the headline `count_active_paying` stays strict. | |
| 497 | + | #[tracing::instrument(skip_all)] | |
| 498 | + | pub async fn count_trialing_or_grace(pool: &PgPool) -> Result<i64> { | |
| 499 | + | let count: (i64,) = sqlx::query_as( | |
| 500 | + | r#" | |
| 501 | + | SELECT COUNT(*) FROM creator_subscriptions | |
| 502 | + | WHERE status = 'trialing' | |
| 503 | + | OR ( | |
| 504 | + | status = 'canceled' | |
| 505 | + | AND canceled_at IS NOT NULL | |
| 506 | + | AND canceled_at > NOW() - INTERVAL '30 days' | |
| 507 | + | AND grace_enforced_at IS NULL | |
| 508 | + | ) | |
| 509 | + | "#, | |
| 510 | + | ) | |
| 511 | + | .fetch_one(pool) | |
| 512 | + | .await?; | |
| 513 | + | Ok(count.0) | |
| 514 | + | } | |
| 515 | + | ||
| 475 | 516 | /// Check whether a user is in the 30-day cancellation grace period. | |
| 476 | 517 | /// | |
| 477 | 518 | /// Returns `true` if the subscription is canceled but within 30 days of cancellation |
| @@ -87,6 +87,8 @@ | |||
| 87 | 87 | DocIndexTemplate, | |
| 88 | 88 | // Pricing calculator | |
| 89 | 89 | PricingTemplate, | |
| 90 | + | // Platform economics + runway disclosure | |
| 91 | + | EconomicsTemplate, | |
| 90 | 92 | // Use cases | |
| 91 | 93 | UseCasesTemplate, | |
| 92 | 94 | // Team |