Repository navigation
feat(billing): plans sheet schema_version 2 - #21397
Conversation
# Conflicts: # config/feature-flags.yaml
The dark storefront now returns the copy-deck contract — hours, headers, and checkout policy — from one English copy module, still behind the same flag. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Pyright rejected an always-true dict check, an unnarrowed currency, and checkout actions typed as plain strings. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
Apply the latest plans-sheet copy deck: cancel-scheduled resubscribe only, yearly switch savings, compact current cards, and singular month-free. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The plans route never shipped a decoder, and the first client accepts only schema 2 on that same path. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The merge of origin/main reformatted files this branch does not own, which grew the line-count ratchet against main. Co-authored-by: Cursor <cursoragent@cursor.com>
Those paths belong to origin/main. The pinned Dart formatter rewrapped them during the merge, which the line-count ratchet counts as growth. Co-authored-by: Cursor <cursoragent@cursor.com>
A scheduled cancellation was advertising the other interval, which checkout rejects. Monthly cards now check out the yearly price alone, and current cards send no bullets. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Monitor fix pass — both failing checks were inherited main red, resolved by merging main.
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c66931824
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if amount_minor is not None and interval in {'month', 'year'}: | ||
| period = 'mo' if interval == 'month' else 'yr' | ||
| price = money(amount_minor) |
There was a problem hiding this comment.
Render the current subscription in its actual currency
For a recognized JPY Plus or Unlimited subscription, build_storefront passes Stripe's zero-decimal unit_amount here without its currency, while money() always divides by 100 and prefixes $. For example, an active ¥4,800 subscription is reported as $48.00, causing the plans sheet to misstate the user's billing amount. Carry the Stripe currency into current_line and format zero-decimal currencies correctly, or omit unsupported amounts.
Useful? React with 👍 / 👎.
| def budget_units_for(plan_id: str) -> int | None: | ||
| """Plan-catalog usage budget when declared, otherwise the presentation rate card.""" | ||
| for name in ('USAGE_BUDGET_UNITS', 'PLAN_USAGE_BUDGET_UNITS'): | ||
| declared = getattr(plan_catalog, name, None) | ||
| if isinstance(declared, dict) and plan_id in declared: | ||
| return int(declared[plan_id]) | ||
| value = load_copy().get('budget_units', {}).get(plan_id) |
There was a problem hiding this comment.
Read usage budgets from the canonical plan catalog
When a paid ladder budget changes in plan_catalog.json, this lookup never sees it because the generated catalog exposes budgets under ENTITLEMENT_VERSIONS['ladder_v1']['monthly_budgets'], not either of these nonexistent top-level attributes. It therefore silently retains the duplicated literals in storefront_copy_v1.json, making hours_line and remaining-hours meter copy stale after an otherwise valid catalog update. Resolve the budget through the config.plan_catalog/rate-card API instead of maintaining a copied plan set.
AGENTS.md reference: backend/AGENTS.md:L155-L155
Useful? React with 👍 / 👎.
| action=action, | ||
| cta_label=cta_label(action=action, tier_plan=current_plan, current_plan=current_plan, locale=locale), | ||
| blocked_line=None, | ||
| compact=_compact(is_current=True, action=action, prices=current_prices), | ||
| prices=current_prices, |
There was a problem hiding this comment.
Suppress Resubscribe without a usable current price
When a non-legacy paid subscription is scheduled for cancellation and the current Stripe price cannot be retrieved, this fallback still emits action: checkout and cta_label: Resubscribe while prices is empty. A retained current price that is no longer purchasable similarly produces a CTA whose ID the checkout endpoint rejects. In either case the client renders a reactivation button that cannot succeed; gate the action and CTA on an exact current price that is both retrieved and accepted by the checkout guard.
Useful? React with 👍 / 👎.
| text = copy_for(locale)['current'] | ||
| note = text['legacy_note'] if legacy else None |
There was a problem hiding this comment.
Derive discontinued-plan copy from catalog lifecycle
Here legacy only means that the current price lacks a ladder-v1 entitlement binding, but the code treats it as proof that the plan is no longer sold. Current catalog plans such as Plus and Unlimited v2—including their recognized JPY prices—can therefore receive the false message “We don't sell this plan anymore.” Determine this note from the canonical plan lifecycle/storefront state, or use neutral legacy-entitlement copy instead.
AGENTS.md reference: backend/AGENTS.md:L155-L155
Useful? React with 👍 / 👎.
| want_switch = is_current and current_interval == 'month' and not resubscribe_only and 'year' in offered | ||
| prices = _prices_for(rows, offered=offered, locale=locale, switch_yearly=want_switch) |
There was a problem hiding this comment.
Keep current-tier prices tied to the subscriber's price
When a ladder-v1 price is retired and replaced, its entitlement binding remains recognized but purchasable(price_id) removes it from rows, so this current-tier card is built from the replacement prices instead of current_row. An annual subscriber can consequently see the new annual amount as their current price, while a monthly subscriber's “save” label is calculated against the new monthly list price rather than what they actually pay and can claim savings even when switching costs more. Use the stored current price for current-card display and as the monthly savings baseline.
Useful? React with 👍 / 👎.
What changed and why
Merged
origin/main(including #21367 as61e1100) so this draft's diff is only schema_version 2. The plans sheet now follows the latest copy deck.Your {Plan} plan ends {date}, current lineYou're on {Plan} · {$price}/mo(yearly{$price}/yr). Only the current tier ischeckoutwithResubscribeon its current price. Every other tier isaction: none,cta_label: null, andblocked_line: "Resubscribe first to change plans". No recommended badge. The current tier is first. No switch-to-yearly while cancel-scheduled.cta_labelSwitch to yearly · save {saving}(12× monthly − annual, in the price currency) so the client renders it verbatim.compact: truewhen they have no tier button and no price CTA, so the client drops bullets.1 month free, not1 months free.STOREFRONT_LADDER_V1_ENABLEDis unchanged. No Stripe network, production data, flag flips, or deploys. Mobile client #21363 consumesbackend/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.shwithBACKEND_UNIT_TEST_FILE_LISTfor 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, pricecta_label).backend/tests/unit/test_storefront_off_path.py— v1/v1/payments/available-plansbytes stay independent of the flag.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