Superwall mobile-only subscription plans (Lite / Plus / Max)#7215
Draft
mdmohsin7 wants to merge 37 commits into
Draft
Superwall mobile-only subscription plans (Lite / Plus / Max)#7215mdmohsin7 wants to merge 37 commits into
mdmohsin7 wants to merge 37 commits into
Conversation
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.
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.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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`)
2. PlanType enum extension + `Subscription.source` (commit `65dd81da2`)
What's NOT in this PR yet (incoming on the same branch)
Locked product decisions (per manager)
Test plan