feat(billing): plans sheet schema_version 2

Merged
GI
2d ago
+4466-810

What changed and why

Merged origin/main (including #21367 as 61e1100) so this draft's diff is only schema_version 2. The plans sheet now follows the latest copy deck.

  • Cancel scheduled: header Your {Plan} plan ends {date}, current line You're on {Plan} · {$price}/mo (yearly {$price}/yr). Only the current tier is checkout with Resubscribe on its current price. Every other tier is action: none, cta_label: null, and blocked_line: "Resubscribe first to change plans". No recommended badge. The current tier is first. No switch-to-yearly while cancel-scheduled.
  • Same tier monthly to yearly: the current tier's yearly price carries cta_label Switch to yearly · save {saving} (12× monthly − annual, in the price currency) so the client renders it verbatim.
  • Current cards set compact: true when they have no tier button and no price CTA, so the client drops bullets.
  • Hours lines always say "of recording". One free month is 1 month free, not 1 months free.

STOREFRONT_LADDER_V1_ENABLED is unchanged. No Stripe network, production data, flag flips, or deploys. Mobile client #21363 consumes backend/tests/fixtures/storefront_v2/ byte-for-byte.

Schema 1 on this route never shipped. The app-client compatibility gate ignores only that unpublished 1→2 response replacement; request changes and any later response break on the route still fail.

Product invariants affected

none

How it was verified

backend/test.sh with BACKEND_UNIT_TEST_FILE_LIST for the storefront unit tests, the v1 off-path byte check, and the app-client OpenAPI/Dart/Swift/TypeScript generator checks. Golden JSON is rendered from fake Stripe prices with no network. scripts/pr-preflight --lane local.

Tests

  • backend/tests/unit/test_storefront_presentation.py — hours 15/100/280/1,200, singular month-free, the plan × interval × cancel × legacy × tier matrix (including resubscribe-only and the yearly switch CTA), and twelve golden scenarios.
  • backend/tests/unit/test_storefront.py — route, flag, cache, usage seam, and OpenAPI fields (blocked_line, compact, price cta_label).
  • backend/tests/unit/test_storefront_off_path.py — v1 /v1/payments/available-plans bytes stay independent of the flag.
  • App-client generator checks cover the regenerated Dart, Swift, and TypeScript clients.

UI evidence

UI-Evidence: none -- flag-off API contract and generated clients; no rendered plans UI in this PR.

Failure class (fixes)

Failure-Class: none