Skip to content

Superwall mobile-only subscription plans (Lite / Plus / Max)#7215

Draft
mdmohsin7 wants to merge 37 commits into
mainfrom
caleb/superwall-mobile-plans
Draft

Superwall mobile-only subscription plans (Lite / Plus / Max)#7215
mdmohsin7 wants to merge 37 commits into
mainfrom
caleb/superwall-mobile-plans

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

Summary

Backend-side scaffolding for the new mobile-only IAP plan tiers — Lite ($9.99 mo / $79.99 yr), Plus ($29.99 / $199.99 yr), Max ($49.99 / $299.99 yr) — purchased via Superwall through App Store IAP / Google Play Billing. Replaces the current Stripe-Checkout-WebView mobile flow (App Store IAP-policy borderline). Existing $49.9K MRR Stripe book stays untouched.

Tracking: BasedHardware/omi-enterprise#23

This is a draft — only the first two TDD increments. Remaining work (Superwall webhook handler, catalog filter, Flutter SDK wiring, desktop "manage on mobile" gating) lands in follow-up commits on this branch.

Phases shipped so far

1. Firestore-backed plan caps registry (commit `e3cd0f27d`)

  • New `backend/database/plan_caps_config.py` — mirrors the existing `phone_call_config` pattern. Caps live in `app_config/plan_caps` Firestore doc with 60s in-memory cache. Tunable without redeploy.
  • `utils/subscription.get_plan_limits` now delegates to the config layer. Behavior identical to the pre-refactor branch-per-plan implementation when the doc is missing/empty.
  • Reserves slots for `superwall_product_map` (used by upcoming webhook handler) and `platform_overrides` (forward-compat for desktop-extra entitlements).

2. PlanType enum extension + `Subscription.source` (commit `65dd81da2`)

  • New `PlanType.lite`, `plus`, `max` with default caps from issue Update README (hiccup on Arduino IDE) #23.
  • `SubscriptionSource` enum (`stripe` | `superwall_ios` | `superwall_android`) on `Subscription`. Defaults to `stripe` so existing rows behave as before.
  • `superwall_subscription_id` field reserved for webhook event dedup.
  • Legacy `'pro'` `missing` alias preserved (5 live user docs still store `plan='pro'` literally → continue resolving to `architect`).
  • New plans deliberately NOT in `OVERAGE_ENABLED_PLANS` — StoreKit / Play Billing can't do usage-based overage charges, so they hard-cap.

What's NOT in this PR yet (incoming on the same branch)

  • Superwall webhook router (`/v1/superwall/webhook` with svix verification + idempotency)
  • Catalog endpoint filter (mobile shows Lite/Plus/Max only for new users; legacy Stripe subscribers keep their current plan view)
  • Flutter SDK wiring (paywall trigger swap, identify-on-login, restore-purchases)
  • Desktop Swift consumption of the existing-but-unused `showSubscriptionUI` field + Manage routing by source
  • Superwall dashboard config (entitlements, products, paywall, webhook URL — done via Superwall MCP)
  • App Store Connect / Play Console product creation

Locked product decisions (per manager)

  • Superwall = new subs only. No Stripe→mobile migration logic.
  • Catalog policy: mobile-only Lite/Plus/Max for new users. Legacy Stripe subscribers continue to see their current plan + Stripe portal Manage button. Mobile plans appear to legacy users only after they cancel.
  • Cross-platform entitlement: mobile-bought sub unlocks features on mobile + desktop (account-wide). Apple Guideline 3.1.3(b) explicitly permits.
  • No `SUPERWALL_ENABLED` feature flag — ship unconditionally; staged rollout via Superwall dashboard audience targeting.
  • Caps tunable at runtime via Firestore (no redeploy).

Test plan

  • 29 new unit tests (15 in `test_plan_caps_config.py`, 14 in `test_new_plans_enum.py`) — all passing
  • Existing chat-quota / subscription-plan tests unchanged behavior — passing
  • Smoke test cap-config tunability: change a Firestore field, wait 60s for cache TTL, observe new cap (manual after deploy)
  • Verify no regression in `/v1/users/me/subscription` for legacy Stripe-sub users (manual after deploy)
  • End-to-end Superwall purchase round-trip on TestFlight (post-webhook-handler commit)

mdmohsin7 and others added 30 commits May 9, 2026 10:29
Adds `database/plan_caps_config.py` so per-plan caps (chat questions,
transcription seconds, chat cost) can be tuned from the
`app_config/plan_caps` Firestore doc without a redeploy. Mirrors the
existing `phone_call_config` pattern with a 60s in-memory cache.

`utils/subscription.get_plan_limits` (and `get_basic_plan_limits`) now
delegate to the new module. Behavior is identical to the pre-refactor
branch-per-plan implementation when the Firestore doc is absent or
empty — the env-var defaults are mirrored exactly.

The doc also reserves slots for `superwall_product_map` (used by the
upcoming Superwall webhook handler) and `platform_overrides` (forward-
compat for future desktop-extra entitlements).

Tests: 15 new cases pin fallback defaults, Firestore overrides,
null-as-unlimited, typo rejection, and per-platform layering. Existing
chat-quota and subscription-plan tests continue to pass against the
refactored `get_plan_limits`.
Phase 2 of the Superwall mobile-plan rollout (BasedHardware/omi-enterprise#23).

Adds three new ``PlanType`` values for the App Store / Play Billing IAP
tiers — ``lite`` ($9.99/$79.99 yr, 100 msgs / 1500 mins), ``plus``
($29.99/$199.99 yr, 300 msgs / 4000 mins), ``max`` ($49.99/$299.99 yr,
unlimited). Each is wired into ``PAID_PLAN_TYPES``, ``PLAN_DISPLAY_NAMES``,
and the ``plan_caps_config`` fallback registry with caps from the issue
spec. They're intentionally NOT in ``OVERAGE_ENABLED_PLANS``: StoreKit /
Play Billing can't do usage-based overage charges, so these tiers
hard-cap at the cap.

The legacy ``'pro'`` ``_missing_`` alias is preserved — five live user
docs still store ``plan='pro'`` literally and continue to resolve to
``architect``.

Adds ``SubscriptionSource`` enum (``stripe`` | ``superwall_ios`` |
``superwall_android``) and a ``Subscription.source`` field defaulting to
``stripe`` so existing Firestore rows behave exactly as before.
``superwall_subscription_id`` slot reserved for the upcoming webhook
handler to dedupe events.

Tests: 14 new cases pin enum values, paid-plan classification, display
names, default caps for each new tier, and the source field's default +
explicit behavior. Existing chat-quota / subscription-plan tests still
pass against the extended enum.
Phase 2 of the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23). Inbound side of the integration —
ingests normalized purchase lifecycle events from Superwall and applies
them to ``user.subscription`` in Firestore.

New endpoint: ``POST /v1/superwall/webhook``
  - svix v1 signature verification over the raw request body (constant-
    time HMAC-SHA256 compare; supports multi-signature header for key
    rotation; rejects timestamps > 5 min stale to defeat replay).
  - Idempotency via ``superwall_events/{svix_id}`` Firestore doc — svix
    retries that hit a 2xx don't double-apply the purchase. Persist
    AFTER successful dispatch so a transient failure leaves no
    orphaned marker.
  - product_id → PlanType resolved via the
    ``app_config/plan_caps.superwall_product_map`` Firestore field
    (added in the previous commit). New SKUs ship without redeploy.

Per-event handlers cover the full Superwall lifecycle:
  initial_purchase / renewal / cancellation / uncancellation /
  expiration / billing_issue / product_change / subscription_paused.

Conflict handling: if the resolved omi user already has an active
Stripe sub, the Superwall sub is still accepted (Apple/Google have
already charged) but a WARNING is logged for ops + the app surfaces a
manage-existing-sub toast in a later phase. We do NOT auto-cancel
Stripe — only the user can request that.

Source detection (ios vs android) is inferred from event metadata's
``store`` field; defaults to ``superwall_ios`` when missing.

Tests: 22 new cases pin signature verification (good/bad/tampered/
stale/missing/multi-sig), product resolution, every event handler
including the Stripe-conflict path, source detection, and dispatch-
level error paths (unknown event type, missing uid, unknown product).
…lict guard

Phase 3 of the Superwall rollout
(BasedHardware/omi-enterprise#23). Routes the right shape of catalog to
each (platform, subscription source) combination so the new mobile
SKUs can ship without disrupting the existing Stripe book.

``GET /v1/payments/available-plans``:
  - Mobile (ios/android), no sub OR active Superwall sub →
    Lite / Plus / Max display catalog. Paywall is rendered by the
    Superwall SDK in the app; this list is just the data fallback for
    legacy surfaces (the existing plans_sheet, desktop fallback).
  - Mobile, active legacy Stripe sub → empty catalog. Per Q3=C such
    users keep seeing their current plan + Stripe portal Manage button
    and aren't tempted to double-subscribe via Superwall.
  - Desktop / web, active Superwall sub → empty catalog. Per Q4 there
    is no new desktop purchase path; client routes Manage to "iOS
    Settings → Subscriptions" / "Play Store".
  - Desktop / web, otherwise → existing legacy Stripe catalog,
    completely unchanged.

``POST /v1/payments/checkout-session``:
  - Defense-in-depth 422 if the user already holds an active Superwall
    sub. The mobile + desktop UIs already hide the trigger in this
    state but a stray call would otherwise create a duplicate billing
    rail with no easy reconciliation path.

The mobile-catalog helpers live in ``utils/superwall_catalog`` (not in
``routers/payment``) so they're testable without dragging in the
router's heavy deps (opuslib, fair_use, notifications).
``_PAID_PLAN_VALUES`` is a local constant rather than an import from
``utils.subscription`` to dodge a pre-existing circular-import hazard
(``utils.subscription`` ↔ ``database.users``).

Tests: 14 new cases pin the catalog shape per source/platform and the
predicate helpers. Total Superwall-rollout test count now: 65.
Phase 4 of the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23). Pulls the Superwall Flutter SDK
into the app, configures it standalone (no RevenueCat / no
PurchaseController — Superwall drives StoreKit + Play Billing
directly), and tags every authenticated session with the omi uid so
the backend webhook (POST /v1/superwall/webhook) can reconcile
events.

Changes:
- pubspec.yaml: add superwallkit_flutter ^2.4.12. Required envied
  ^1.2.0 + envied_generator ^1.2.0 + flutter_gen_runner ^5.14.1
  bumps to break the analyzer-version conflict between the new SDK's
  pigeon ^26.1.5 and the older envied 1.1.1's analyzer <8.0.0
  constraint. Lockfile reshuffles 174 lines of transitive deps;
  build_runner regen confirms the generators still produce the same
  shape of g.dart.
- AndroidManifest.xml: register SuperwallPaywallActivity (per
  superwall.com/docs/flutter/quickstart/install).
- models/subscription.dart: extend PlanType with `lite`, `plus`,
  `max` so deserialization of the new tiers doesn't silently
  downgrade to `basic` via the unknownEnumValue fallback. g.dart
  regenerated to match.
- providers/usage_provider.dart: extend `_isPaidPlan` /
  `isOutOfCredits` to recognize the new tiers as paid (mirrors the
  backend `PAID_PLAN_TYPES` set added in 65dd81d).
- services/superwall_service.dart (new): thin singleton wrapper —
  configure(), identify(uid), reset(), restorePurchases(),
  listenToStatus(). API keys read from
  --dart-define=SUPERWALL_API_KEY_IOS / _ANDROID at build time. No-op
  when keys are unset (lets the app boot in local dev / pre-launch
  preview without a Superwall workspace configured).
- main.dart: initialize() Superwall after Firebase init, then
  identify(uid) when the user is authenticated.

What's NOT in this commit (incoming on the branch):
- Paywall trigger swap in plans_sheet.dart (split into the next
  commit so this one stays reviewable as "SDK wired + identified").
- Cap-hit placement registration at chat / transcription trigger
  sites.
- Conflict-toast UX when both Stripe + Superwall subs exist.
…lansSheet)

Phase 4 (continued) of the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23). Splits the acquisition surface
from the management surface: non-subscribers and Superwall sub
holders see the Superwall paywall when they hit Upgrade or a cap;
legacy Stripe subscribers keep the existing PlansSheet so they can
still see their current plan and reach the Stripe customer portal
via Manage.

New: ``utils/paywall_router.dart`` exposes
``showUpgradePaywall(context, placement: …)``. Decision tree:
  - ``showSubscriptionUI=false`` (App Review build / hidden version) →
    short-circuit, no UI shown.
  - User has active legacy Stripe sub (source=stripe, status=active,
    plan ∈ {unlimited, operator, architect}) →
    open the existing PlansSheet via a wrapper that supplies the
    animation controllers when callers don't pass their own.
  - Otherwise (no sub, or Superwall sub) →
    ``Superwall.shared.registerPlacement(placement)``; the SDK
    presents the paywall configured in the Superwall dashboard.

Subscription model gains a ``source`` field
(``SubscriptionSource.stripe`` / ``superwallIos`` / ``superwallAndroid``)
mirroring the backend enum added in 65dd81d. ``@JsonValue`` on each
variant keeps the wire format snake_case so the existing book (which
defaults to ``stripe`` server-side) round-trips unchanged. Adds
``superwallSubscriptionId`` slot too so the dart model stays a faithful
mirror of the Firestore doc.

Trigger sites switched to the router:
  - ``pages/settings/usage_page.dart::_showPlansSheet`` →
    ``showUpgradePaywall(context, placement: 'upgrade_settings', …)``.
  - ``widgets/freemium_switch_dialog.dart::checkAndShowPaywall`` →
    ``showUpgradePaywall(context, placement: 'transcription_minutes_exceeded')``;
    the inline ``_PlansSheetWrapper`` is removed (now lives in
    ``paywall_router.dart`` as the wrapper for legacy users).

``flutter analyze`` clean on changed files (8 pre-existing warnings in
main.dart — gitignored Firebase options + avoid_print — unchanged).
…le subs

Phase 5 of the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23). Desktop has no purchase path for
the new mobile SKUs (per Q4 — the entitlement is account-wide so the
mobile-bought sub already unlocks features on Mac), so the macOS app
stops offering Stripe checkout when the user is on a Superwall sub
and points them at iOS Settings / Play Store for management instead.

backend (routers/users.py):
  Extends the ``show_subscription_ui`` calc on
  ``/v1/users/me/subscription``. When the request comes from desktop
  or web and the user holds an active Superwall sub
  (source ∈ {superwall_ios, superwall_android}), set the flag to
  ``False`` so the client hides its purchase surfaces. Mobile callers
  are unaffected — the Superwall SDK paywall is the management surface
  for them.

desktop (APIClient.swift):
  - SubscriptionPlanType: add ``lite`` / ``plus`` / ``max`` cases so
    decoding doesn't throw when the backend returns one of the new
    mobile tiers.
  - SubscriptionSourceType (new): String-coded enum mirroring the
    backend ``SubscriptionSource`` (stripe / superwall_ios /
    superwall_android) with proper raw values for snake_case wire
    format.
  - UserSubscriptionInfo: add optional ``source`` field +
    ``superwallSubscriptionId`` slot. ``effectiveSource`` falls back
    to ``.stripe`` when the field is missing so the existing book
    keeps routing Manage to the Stripe customer portal.

desktop (SettingsPage.swift):
  - ``shouldShowPlanPurchaseOptions``: respect the new
    ``showSubscriptionUI`` flag — hide the "Choose a plan" cards when
    the backend says so.
  - Manage button: branches on ``effectiveSource``. Stripe-sourced
    subs keep the existing customer-portal flow; Superwall-sourced
    subs render a non-interactive "Manage in iOS Settings →
    Subscriptions" / "Manage in the Play Store" hint instead.
  - New empty-state card "Subscriptions are managed on mobile" shown
    when the purchase UI is hidden AND the user has an active
    Superwall sub (so App-Review-hidden builds — which also flip
    ``showSubscriptionUI`` off — don't accidentally surface this
    message to reviewers).

No backend tests affected (the change is one branch in an existing
endpoint and uses already-imported types). Desktop CI will compile-
check the Swift changes; xcrun isn't available on this Linux VPS.
…ses UI

Phase 4 (continued, "cap-hit placements + restore-purchases" rung of
the TDD ladder) for the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23).

Two wires:

1. Chat quota-exceeded → paywall.
   When the backend returns 402 on a chat send, MessageProvider
   already substitutes a canned "you've hit your monthly limit"
   reply. It now ALSO calls
   ``showUpgradePaywall(ctx, placement: 'chat_quota_exceeded')`` so
   the user is dropped straight into the Superwall paywall (or the
   legacy PlansSheet, depending on their subscription source) instead
   of having to hunt for the upgrade button. Fire-and-forget — the
   message-rendering path doesn't await the sheet. Both 402 sites
   in MessageProvider (regular send + agent send) wired.

2. Restore Purchases.
   New text button under the upgrade CTA on the basic-plan view of
   ``UsagePage`` (mobile only — Apple/Google manage receipts per
   Apple ID / Google account). Calls
   ``SuperwallService.restorePurchases()``, then forces a
   ``UsageProvider.fetchSubscription()`` so the cached subscription
   refreshes from the backend (restore can re-apply an entitlement
   the webhook already wrote). Snackbar reflects success/failure.

Adds 3 l10n keys (``restorePurchasesAction`` / ``…Success`` /
``…Failed``) translated into all 47 non-en ARBs and regenerated via
``flutter gen-l10n``. Pre-existing 2-untranslated-per-locale baseline
unchanged.

``flutter analyze`` clean on changed files (5 pre-existing issues
unrelated).
Phase 4 (final wiring) for the Superwall mobile-plan rollout
(BasedHardware/omi-enterprise#23). Closes the last UX gap: if a user
already had an active Stripe (legacy web) subscription when they
purchased a Superwall mobile sub, they're now being charged on two
billing rails for the same uid. The backend webhook accepts the new
sub (Apple/Google have already charged the user) and logs a warning,
but it can't auto-cancel the Stripe one — only the user can.

This commit adds the client-side surface that asks them to:
  - SuperwallService.watchForStripeConflict() subscribes to the
    Superwall subscriptionStatus stream once at startup.
  - When the SDK reports an Active status AND the backend's stored
    subscription has source ∈ {superwall_ios, superwall_android} AND
    a non-empty stripe_subscription_id, the service shows a one-time
    dialog ("Two subscriptions active" / "Open billing portal") that
    routes Manage → existing Stripe customer-portal flow via
    PaymentWebViewPage.
  - "Shown once" is persisted via SharedPreferencesUtil under
    superwall_stripe_conflict_dialog_shown so the dialog doesn't nag
    on subsequent launches even if the user dismisses without action.
  - Mounted-context guards before AND after the awaited
    fetchSubscription() — listener can fire long after subscription
    state changes.

Wired from main.dart's _init() right after the existing
SuperwallService.identify(uid) call.

Adds 4 new l10n keys (dualSubscriptionDetectedTitle / Body /
Dismiss / Manage) translated into all 47 non-en ARBs.

flutter analyze clean on both changed files; 8 pre-existing issues in
main.dart (gitignored Firebase options + avoid_print) unchanged.
Glues the unit-tested layers together via a real ``FastAPI`` +
``TestClient``, so integration boundaries (signature verification on
raw bytes, idempotency table writes, Pydantic round-trips, route
registration, the checkout-session 422 guard) all exercise their
happy + sad paths against the real ``routers/superwall.py`` and
``routers/payment.py``.

Six cases:
  - initial_purchase webhook → plus plan written, source=superwall_ios,
    superwall_subscription_id captured
  - play_store event metadata routes source=superwall_android
  - cancellation → cancel_at_period_end=True; subsequent expiration
    reverts plan to basic
  - bad svix signature → 401, no DB write
  - duplicate svix-id → second call returns 'duplicate' and
    update_user_subscription is invoked exactly once
  - active Superwall sub blocks /v1/payments/checkout-session with 422

Test setup mirrors ``test_available_plans_resilience.py``: heavy
infra stubbed at module load (firebase_admin, database submodules,
utils leaves) so the file is fully self-contained — no Firestore /
Redis / Stripe network needed in CI. Idempotency table + subscription
state live in-process via dicts the stub functions read/write.

A subtle setup bit: import the real ``database`` package first as a
sys.modules anchor so subsequent ``import database.plan_caps_config``
resolves to the on-disk module (the stubs we register cover only
the leaves we want to mock; everything else stays real).

Adds the file to test.sh so CI runs it.
Apple sends bare bundle-style product ids
(``com.omi.app.lite_monthly``); Google Play v5 sends the triple
``<product>:<base_plan_id>:<offer_id>`` (e.g.
``com.omi.app.lite_monthly:monthly:sw-auto``). Without normalization,
the Android purchase webhook resolves to ``unknown_product`` because
the ``superwall_product_map`` Firestore entries key only on the bare
product id.

Adds ``_normalize_product_id`` (split on first ``:``) and threads it
through ``resolve_plan``. Single Firestore map now serves both
platforms — no need to enumerate every Play triple.

Pins behavior with one new test
(``test_play_store_triple_resolves_via_normalized_key``); both
``com.omi.app.lite_monthly:monthly:sw-auto`` and the bare
``com.omi.app.lite_monthly`` resolve to ``PlanType.lite`` from the
same map entry.
The rest of the app's vendor secrets (OpenAI, Posthog, Growthbook,
Intercom, Google Maps/Auth, etc.) all flow through envied — annotated
fields on ``DevEnv`` / ``ProdEnv`` that ``build_runner`` bakes into
obfuscated ``*.g.dart`` outputs. The earlier Superwall wiring
(95c1994) routed around that pipeline by reading
``String.fromEnvironment(SUPERWALL_API_KEY_IOS)`` directly, which
required every build to remember the ``--dart-define`` flags and left
the keys un-obfuscated in the binary.

Switches Superwall to the same envied pattern:
  - ``EnvFields`` gains ``superwallApiKeyIos`` / ``superwallApiKeyAndroid``.
  - ``DevEnv`` + ``ProdEnv`` get the ``@EnviedField`` annotations
    (obfuscate=true) reading ``SUPERWALL_API_KEY_IOS`` /
    ``SUPERWALL_API_KEY_ANDROID`` from ``.dev.env`` / ``.env``.
  - ``SuperwallService.initialize()`` reads ``Env.superwallApiKeyIos`` /
    ``Env.superwallApiKeyAndroid`` instead of the dart-define constants.

Local setup: add the two keys to ``.env`` (and ``.dev.env`` for the
dev flavor), then ``flutter pub run build_runner build
--delete-conflicting-outputs`` to regenerate ``dev_env.g.dart`` /
``prod_env.g.dart`` (both gitignored). The keys land in the build
binary obfuscated alongside the rest of the vendor secrets.

The current Superwall workspace public API keys (safe to share — they
identify the workspace, not authorize any privileged action):
  SUPERWALL_API_KEY_IOS=pk_yOK3LmjdOUa-XWV9O_YVj
  SUPERWALL_API_KEY_ANDROID=pk_L539Ii_L5d0qzOKflWOy3
Resolves l10n conflicts between Superwall paywall keys (restorePurchases*,
dualSubscriptionDetected*) on this branch and bulk-action keys
(bulkExportAlreadyExported, bulkDeleteFailed) on main. Both sets kept;
.dart files regenerated from .arb via flutter gen-l10n.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolves the Superwall mobile paywall gate per-user from the existing
`app_config/plan_caps` Firestore doc. Two fields:

  superwall_enabled: bool        — global default (off until prod-ready)
  superwall_test_uids: [str]     — override list, internal testers see Superwall regardless

Resolution: uid in the override list returns True; otherwise the global
flag (default False). 60s TTL via the existing module cache means flag
flips propagate within a minute without redeploy.
Surfaces the per-user Superwall flag on /v1/users/me/subscription so the
client can route upgrade triggers without a separate config endpoint.
Default False matches the backend default, keeping older clients on the
legacy PlansSheet path.
Calls database.plan_caps_config.is_superwall_enabled(uid) and passes the
result through to the response model. The uid is the only input the
helper needs — global flag + override list are read from Firestore.
Pins the resolution order: uid in superwall_test_uids overrides the
global flag, missing/null/empty fields are safe, empty/None uid skips
the override lookup. All 8 pass alongside the existing 15 plan_caps
tests.
Mirrors the backend addition. Default false so a missing field on older
servers / pre-flag-config docs deserializes as off. `.g.dart` regenerated
via build_runner.
Read by the paywall router to decide between Superwall and the legacy
PlansSheet. Default false when the subscription response hasn't loaded
yet — matches the backend default and keeps the safe fallback.
Same as initialize() but named to make the call site self-documenting:
'only configure Superwall when we're about to actually use it.' Used by
the paywall router to defer SDK init until the flag actually routes a
trigger to Superwall.
Master gate now: when usage.superwallEnabled is false OR the user is a
legacy Stripe subscriber, route to PlansSheet. Only when the flag is on
do we lazy-configure the SDK (ensureConfigured + identify + watch for
Stripe conflict) and registerPlacement.

Result: with the flag off, the Superwall SDK never initializes and makes
no network calls — the build behaves identically to the pre-Superwall
codebase. Flipping the global flag (or adding a uid to the test list)
takes effect within ~60s of the next subscription fetch.
SDK configure / identify / watchForStripeConflict all moved to lazy
init in paywall_router.dart so flag-off users never hit Superwall's CDN
during cold start. Drops the now-unused services/superwall_service.dart
import.
The mobile top tier was internally named 'max' but the marketing /
display name is 'Unlimited'. Renaming the enum to 'unlimited_v2' makes
the internal id match the user-facing concept.

'unlimited_v2' (not 'unlimited') because the legacy Stripe plan already
owns 'unlimited' as its enum value (now displayed to users as 'Neo').
Keeping the legacy enum intact avoids a data migration on live Stripe
subscribers; the new mobile tier coexists under a distinct internal id.

App Store / Play Store SKUs renamed in lockstep:
  com.omi.app.max_monthly -> com.omi.app.unlimited_v2_monthly
  com.omi.app.max_yearly  -> com.omi.app.unlimited_v2_yearly

Touches: PlanType enum, PAID_PLAN_TYPES, PLAN_DISPLAY_NAMES, the
Firestore caps fallback, and the static catalog helper. Manual steps
to delete + recreate the IAPs in App Store Connect / Play Console /
Superwall dashboard are tracked in SUPERWALL_LAUNCH_CHECKLIST.md.
Mirrors the production-code rename across all four affected test files.
Also adds a regression test (test_unlimited_v2_distinct_from_legacy_unlimited)
that pins the contract: the new mobile Unlimited tier and the legacy Neo
plan must remain distinct enum values, even though both are conceptually
'unlimited' to a user.
Mirrors the backend rename. JsonValue('unlimited_v2') annotation keeps
the wire format consistent with the backend's snake_case enum. Dart
camelCase variant 'unlimitedV2' avoids the lint warnings that come
with snake_case enum names. .g.dart regenerated via build_runner.
… steps

New checklist at the repo root documenting everything outside the codebase:
- Apple App Store Connect (Paid Apps Agreement, IAP product creation,
  App Store Server Notifications, sandbox testers)
- Google Play Console (subscriptions + base plans, RTDN setup, service
  account for receipt verification, BILLING permission)
- Superwall dashboard (entitlements, products, paywall, campaign,
  webhook endpoint)
- Backend Firestore (superwall_product_map, superwall_enabled flag,
  superwall_test_uids override list)
- Sandbox testing checklist + App Review submission flow + production
  rollout via the feature flag

Updated for the max -> unlimited_v2 rename in this commit series.
…l_enabled

Regression: with superwall_enabled=false, mobile users opening PlansSheet
still saw the new Lite/Plus/Unlimited tiers because the catalog endpoint
only branched on (platform, source) — it didn't honor the flag the
client-side paywall_router was already gating on. So the legacy sheet
loaded the new SKUs from this endpoint and rendered them.

Fix: the entire mobile-specific routing (mobile catalog + Superwall-sub
desktop empty branch) now requires is_superwall_enabled(uid) == True.
With the flag off, mobile users fall through to the legacy Stripe
catalog same as desktop — making the build behave identically to the
pre-Superwall codebase regardless of platform.
Two cases covering the bug:
  - flag ON + mobile + no sub → all 6 new SKUs in response
  - flag OFF + mobile → none of the new SKUs in response (or path errors
    without full Stripe mocking, which is also fine — the regression is
    specifically about NOT returning the new SKUs)

Test count for the Superwall surface: 66 -> 68.
Symptom: backend logs 'event initial_purchase missing app_user_id' even
after a successful sandbox purchase. The webhook fired but Superwall
had no idea which omi user bought.

Root cause: Superwall.configure() is fire-and-forget — it returns
immediately and fires its completion callback later, once the server
config arrives. Our wrapper set _configured=true inside that callback
instead of immediately after configure() returned. Other wrapper
methods (identify, watchForStripeConflict, etc.) all gated on
_configured, so during the queueing window between configure() and
its completion firing they silently no-op'd.

Under the old eager init this race rarely surfaced — main.dart called
configure() early enough that completion had fired by the time the
user reached a paywall. Under the new lazy init (configure on first
paywall trigger), the calls happen back-to-back:

  ensureConfigured() -> configure() returns, _configured still false
  identify(uid)      -> _configured false, RETURNS WITHOUT CALLING SDK
  registerPlacement  -> SDK queues this, paywall renders untagged
  user purchases     -> webhook fires with no app_user_id

Fix: flip _configured=true synchronously right after Superwall.configure()
returns. The SDK accepts (and internally queues) identify/
registerPlacement from the moment configure() returns — no need to
wait for completion. The completion callback now only logs server
config arrival.

Tested behavior under the new path:
  ensureConfigured -> configure() returns + _configured=true
  identify(uid)    -> SDK receives identify (may queue internally)
  registerPlacement -> SDK queues
  -> completion fires, server config arrives
  -> SDK processes queued ops in order: identify tagged, paywall rendered
  -> purchase -> webhook with app_user_id set
Symptom: real Superwall webhooks land with no app_user_id and get
rejected with 'missing app_user_id' even though Superwall.shared.identify()
ran client-side and the user shows up correctly in Superwall's dashboard.

Root cause: the handler was written against an imagined/normalized
payload shape — snake_case top-level fields — that doesn't match what
Superwall actually sends. The real shape (per
https://superwall.com/docs/integrations/webhooks) is:

  {
    "object": "event",
    "type": "initial_purchase",
    "projectId": ..., "applicationId": ..., "timestamp": ...,
    "data": {
      "originalAppUserId": "<uid OR $SuperwallAlias:UUID>",
      "originalTransactionId": "<durable sub id>",
      "transactionId": "<per-charge id>",
      "productId": "com.omi.app.lite_monthly",
      "expirationAt": <ms since epoch>,
      "store": "APP_STORE" | "PLAY_STORE" | "STRIPE",
      ...
    }
  }

Key differences:
  - All purchase data lives under `data`, not at top level
  - Field names are camelCase, not snake_case
  - `originalAppUserId` is the durable user id (was looking for `app_user_id`)
  - `originalTransactionId` is the across-renewals sub id (was `subscription_id`)
  - `expirationAt` is in milliseconds (we store seconds — convert with //1000)
  - `store` values are UPPERCASE (APP_STORE/PLAY_STORE/STRIPE) not lowercase

Handler updates:
  - dispatch_event extracts `data` from outer envelope, passes to handlers
  - Each handler reads from camelCase keys via two helpers:
    _extract_sub_id reads originalTransactionId (falls back to transactionId)
    _extract_period_end_seconds reads expirationAt and converts ms to s
  - _detect_source matches uppercase `store` values
  - dispatch_event rejects `$SuperwallAlias:<UUID>` uids — that prefix
    means the SDK was configured but identify() wasn't called before the
    purchase, so we have no omi user to reconcile to

This is the same root cause we saw in the user-facing log message
'event initial_purchase missing app_user_id' — handler was reading the
wrong key.
mdmohsin7 and others added 7 commits May 11, 2026 10:58
Mirrors the production-code fix. Test payloads now use the real
Superwall envelope shape: top-level type + nested data dict with
camelCase keys (originalAppUserId / productId / originalTransactionId /
expirationAt in ms / store in UPPERCASE).

Adds two new tests:
  - TestDispatch.test_anonymous_alias_returns_error: $SuperwallAlias:UUID
    uid is rejected (would mean identify() never called before purchase).
  - TestSourceDetection.test_stripe_store_defaults_to_ios: 'STRIPE' store
    value isn't a path we use; default to iOS labeling.

Test count for the Superwall surface: 31 -> 33.
New keys litePlan/plusPlan (English only; 48 non-English locales fall
back to English via gen-l10n until translated). Regenerated all
app_localizations*.dart. Restores 4-space ARB indentation that an
earlier jq/json reformat had flipped to 2-space.
Free→'Upgrade to unlimited', Lite/Plus→'Upgrade Your Plan' (caps still
apply, upgrade path kept), top tiers→Manage only. Title now reflects
actual plan (Lite/Plus/Unlimited) instead of binary basic/unlimited.
Superwall's SDK drives StoreKit directly for App Store purchases;
without the framework linked, iOS in-app purchases fail to resolve.
(PhoneCalls group child reorder is incidental Xcode churn.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant