feat(mobile): add flagged contextual paywall sheet and upsell moments

Merged
GI
2d ago
+63946-6338

What changed and why

The mobile plans sheet still reads GET v2/payments/plans?source=&trigger=&locale= and accepts only schema_version 2. Backend strings render verbatim. Chrome stays in l10n.

The thirteen fixtures in app/test/fixtures/storefront_v2/ are byte copies of backend/tests/fixtures/storefront_v2/ at lane/sub-w5-presentation-v2 445c5f7f494 (#21397). legacy_neo.json is new. The byte-compare test still checks those copies when the backend directory is present.

Contract the sheet now honors:

  • Every is_current card publishes bullets: [], and the sheet renders none.
  • Switch-to-yearly: the current tier has action: "checkout", tier cta_label: null, and only the yearly price. The price-level cta_label is the button text (Switch to yearly · save $39.98).
  • Cancel scheduled: the current card publishes only the current price, and yearly_toggle_label: null hides the Monthly/Yearly toggle.
  • legacy_neo renders like legacy_unlimited: current line, legacy note, and Manage billing. No ladder and no checkout.

Flags mobile-plan-storefront and mobile-contextual-upsells stay off. No payment-rail change.

Reconciliation with #21397

#21397 also touches three files under app/:

  • app/lib/backend/schema/gen/storefront_wire.g.dart
  • app/test/backend/storefront_wire_test.dart
  • app/changelog/unreleased/20261013-storefront-presentation-v2.json

Those stay on #21397. The generated DTO is still schema 1 here. Copying the schema 2 generator output without that PR's OpenAPI spec would fail generate_dart_models.py --all --check and export_openapi.py --check. Schema 2 decoding on this branch is PlanStorefront.fromJson, covered by plan_storefront_test.dart.

storefront_wire_test.dart is restored to the schema 1 generated-client test at the merge base, so #21397's rewrite of that file applies cleanly whichever PR merges second. The generated Dart file and the presentation changelog are untouched here for the same reason.

Product invariants affected

none

How it was verified

  • Focused Flutter tests passed (57): test/models/plan_storefront_test.dart, test/widgets/storefront_plans_content_test.dart, test/widgets/storefront_checkout_handoff_test.dart, test/backend/storefront_wire_test.dart, test/unit/plan_storefront_api_test.dart. The handoff test taps Switch to yearly · save $39.98 and reaches upgrade with the yearly price. Cancel-scheduled asserts the toggle is absent.
  • scripts/l10n.py check: 49 locales, 3876 keys. dart analyze on the touched Dart files reported no issues. scripts/pr-preflight --lane local passed 28 checks on this commit; the pre-push gate passed before the push.
  • Hermetic screenshots were regenerated, one per golden (including legacy_neo), plus banner, meter, small-phone light, empty, and error. No production API calls, Stripe writes, deploys, or flag flips.

Checkout predicate

storefrontCheckoutDecision decides both the card button and every tap.

  • The only same-tier purchase is monthly → annual, and only when the live price interval is month. Annual → monthly and every other same-tier price are refused, including when the current card publishes only the yearly price.
  • action: "checkout" may omit the tier cta_label when the price cta_label carries the words. action: current proceeds only when that price has a cta_label. A null price id refuses.
  • Cancel scheduled resubscribes only the current price. Every other price is refused in the predicate, omitted from the sheet, and dropped by the tap guard.
  • Price id, interval, and amount must match the existing catalog or checkout is refused.
  • Upward moves stay allowed when cancellation is not scheduled. A lower ladder tier, legacy, live unlimited, usage-legacy transcription, and an unrecognized current id are refused.

UI evidence

001-mobile-ios-architect-settings

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/001-mobile-ios-architect-settings.png

002-mobile-ios-cancel-scheduled-plus

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/002-mobile-ios-cancel-scheduled-plus.png

003-mobile-ios-free-budget-80

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/003-mobile-ios-free-budget-80.png

004-mobile-ios-free-mcp-cap

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/004-mobile-ios-free-mcp-cap.png

005-mobile-ios-free-pro-gate-proactive

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/005-mobile-ios-free-pro-gate-proactive.png

006-mobile-ios-free-pro-gate-screen

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/006-mobile-ios-free-pro-gate-screen.png

007-mobile-ios-free-settings

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/007-mobile-ios-free-settings.png

008-mobile-ios-legacy-unlimited

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/008-mobile-ios-legacy-unlimited.png

009-mobile-ios-plus-annual-current

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/009-mobile-ios-plus-annual-current.png

010-mobile-ios-plus-budget-80

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/010-mobile-ios-plus-budget-80.png

011-mobile-ios-plus-monthly-yearly-toggle

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/011-mobile-ios-plus-monthly-yearly-toggle.png

012-mobile-ios-pro-settings

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/012-mobile-ios-pro-settings.png

013-mobile-ios-banner

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/013-mobile-ios-banner.png

014-mobile-ios-meter

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/014-mobile-ios-meter.png

015-mobile-ios-small-phone-light

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/015-mobile-ios-small-phone-light.png

016-mobile-ios-empty

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/016-mobile-ios-empty.png

017-mobile-ios-error

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/017-mobile-ios-error.png

018-mobile-ios-legacy-neo

Path: .agent-artifacts/ui-evidence/w6-mobile-storefront/018-mobile-ios-legacy-neo.png

Failure class (fixes)

Failure-Class: none

Reviewer risks

  1. The generated storefront DTO on this branch is still schema 1. Schema 2 request decoding is PlanStorefront. #21397 owns the generator output.
  2. Non-English locales show the English deck chrome for the new keys. plansAndBilling keeps its previous translations for the old sheet.
  3. A server offer still cannot check out unless the existing price catalog matches id, interval, and amount.
  4. Flags stay off. Do not enable them before the backend contract is on the serving API.
  5. legacy_unlimited and legacy_neo are not drawn as ladder cards. The legacy note is the sheet for those goldens.