xtablo-source/docs/superpowers/specs/2026-05-02-expo-apple-iap-unified-billing-design.md
2026-05-02 16:25:32 +02:00

12 KiB
Raw Blame History

Expo Apple IAP Unified Billing — Design Spec

Date: 2026-05-02 Scope: xtablo-expo + apps/api + Supabase

Summary

Add iOS in-app subscriptions to xtablo-expo using RevenueCat, while keeping Supabase as the billing source of truth and Stripe as the existing web billing rail.

The mobile app should:

  • only show a paywall after login/signup
  • only show purchase actions when Supabase says the authenticated organization has no active access
  • only allow the organization billing owner to purchase
  • sell solo and founder/annual in iOS v1
  • keep team as Stripe/web-only in v1

The backend should normalize Apple and Stripe into one internal billing state so existing authorization and upgrade logic can continue to rely on getOrganizationBillingState.

Product Decisions

  • Billing target: mobile-first unified billing
  • iOS billing rail: Apple in-app purchase via RevenueCat
  • Web billing rail: existing Stripe flow remains in place
  • Source of truth for unlocking: Supabase billing state, not client purchase callbacks
  • Purchase attachment model: purchase attaches to the current authenticated user; organization access is inferred through that user being the billing owner
  • Existing active subscribers: if Supabase already reports active access, the Expo app shows status only and does not offer Apple purchase
  • iOS v1 purchasable plans: solo, annual
  • iOS v1 excluded plan: team

External Constraints

  • Apple requires in-app purchase for unlocking digital functionality inside the app. Apps may let users access entitlements bought elsewhere, but direct in-app unlocking of digital features must use Apples purchase flow.
  • Expos current in-app purchase guidance recommends native libraries such as react-native-purchases, and this requires a development/custom build rather than Expo Go.

References:

Architecture

High-level model

  • xtablo-expo handles purchase UI and restore actions through RevenueCat
  • RevenueCat handles StoreKit integration, renewal lifecycle, and Apple-side state changes
  • apps/api receives RevenueCat webhooks and normalizes Apple subscription state into Supabase
  • Supabase stores first-party normalized Apple billing records alongside the existing Stripe-backed data inputs
  • apps/api/src/helpers/billing.ts resolves one shared organization billing state from both sources

Non-goals

  • No attempt to mirror Apple subscriptions into Stripe
  • No direct app unlock from local purchase success
  • No iOS sale of team
  • No Stripe-to-Apple migration flow in v1
  • No Android billing in v1

Supabase Data Model

Add first-party Apple billing tables instead of reusing the stripe schema.

public.apple_customers

Purpose:

  • map xtablo users to RevenueCat customer identity

Suggested columns:

  • id bigint generated ... primary key
  • user_id uuid not null references public.profiles(id)
  • revenuecat_app_user_id text not null
  • original_app_user_id text null
  • last_seen_environment text null
  • created_at timestamptz not null default now()
  • updated_at timestamptz not null default now()

Constraints:

  • unique on user_id
  • unique on revenuecat_app_user_id

public.apple_subscriptions

Purpose:

  • hold normalized current and historical Apple subscription state used by billing resolution

Suggested columns:

  • id bigint generated ... primary key
  • owner_user_id uuid not null references public.profiles(id)
  • revenuecat_app_user_id text not null
  • store_product_id text not null
  • plan text not null
  • status text not null
  • environment text not null
  • store text not null default 'app_store'
  • original_transaction_id text not null
  • transaction_id text null
  • current_period_start timestamptz null
  • current_period_end timestamptz null
  • cancel_at_period_end boolean not null default false
  • revoked_at timestamptz null
  • raw_customer_id text null
  • last_event_type text null
  • created_at timestamptz not null default now()
  • updated_at timestamptz not null default now()

Constraints and indexes:

  • unique on original_transaction_id
  • index on owner_user_id
  • index on status
  • index on current_period_end

public.apple_subscription_events

Purpose:

  • store raw RevenueCat webhook payloads for audit, replay, and idempotency debugging

Suggested columns:

  • id bigint generated ... primary key
  • event_id text not null unique
  • event_type text not null
  • environment text null
  • payload jsonb not null
  • received_at timestamptz not null default now()
  • processed_at timestamptz null

Plan Mapping

Use explicit backend configuration for product mapping. Do not infer plan from display name.

Suggested mapping:

  • solo_ios_monthly -> solo
  • founder_ios_monthly or annual_ios -> annual

Rules:

  • no team Apple mapping in v1
  • active, trialing, grace-period-like states count as valid paid access
  • expired, revoked, refunded, or equivalent inactive states do not

API Design

New webhook route

Add a route such as:

  • POST /api/revenuecat/webhook

Responsibilities:

  • validate webhook authenticity
  • persist raw event to apple_subscription_events
  • resolve xtablo user from RevenueCat app user id
  • upsert apple_customers
  • upsert normalized apple_subscriptions
  • return success even for duplicate already-processed events

Implementation notes:

  • make handler idempotent
  • treat event logging and normalized state upsert as one logical processing unit
  • keep product-to-plan mapping in config/env, not inline literals spread across the codebase

Existing read surfaces

Prefer reusing:

  • GET /api/v1/users/organization

Reason:

  • the payload already contains trial_starts_at, trial_ends_at, required_plan, required_team_quantity, active_subscription_plan, active_subscription_quantity, and is_billing_owner
  • reusing it keeps mobile aligned with the existing web gate

Optional follow-up:

  • add a smaller mobile billing endpoint later if the organization payload proves too heavy

Billing Resolution Changes

Extend apps/api/src/helpers/billing.ts so organization billing state resolves from both Stripe and Apple.

Existing behavior to preserve

  • billing is organization-scoped
  • the owner user determines billing ownership
  • member count and required plan logic stay unchanged
  • trial window logic stays unchanged
  • team seat logic remains derived from Stripe/web billing only in v1

New Apple resolution behavior

Resolve Apple entitlements for the organization owner from apple_subscriptions, then combine them with the current Stripe-derived candidate selection.

Candidate precedence:

  1. highest plan weight: annual > team > solo
  2. healthiest status
  3. latest current_period_end

Combination rules:

  • choose one winning entitlement source; do not add Apple and Stripe quantities together
  • Apple solo satisfies required_plan = solo
  • Apple annual satisfies any paid requirement in v1
  • Apple never satisfies team seat count semantics beyond annual

Quantity rules:

  • Apple solo -> quantity 1
  • Apple annual -> quantity 1, but plan weight makes it satisfy the higher tier

Expo App Changes

Billing state fetch

After authentication, the app should load billing-bearing organization state and keep it available to:

  • settings screen
  • any post-login paywall gate
  • purchase success pending-sync state

This likely means:

  • extending xtablo-expo/stores/auth.tsx bootstrap, or
  • adding a dedicated organization/billing hook consumed by the authenticated app shell

Paywall visibility

The paywall appears only when all of the following are true:

  • the user is authenticated
  • Supabase-backed billing state reports no active access
  • the authenticated user is the organization billing owner
  • the platform is iOS

Do not show Apple purchase CTA when:

  • the org already has active Stripe or Apple access
  • the user is not the billing owner
  • the plan required is team and the app only supports solo and annual purchases in v1

UI flow

Suggested flow:

  1. user logs in
  2. app fetches organization billing state
  3. if access exists, app shows status only
  4. if no access and the user is the billing owner, app shows paywall
  5. user can purchase solo or annual, or restore purchases
  6. on purchase success callback, app shows “syncing purchase” state and polls billing state
  7. unlock only after the backend-normalized Supabase state reflects access

Placement

Recommended first placement:

  • billing entry point in xtablo-expo/app/(app)/(tabs)/settings.tsx

Acceptable alternative:

  • a dedicated authenticated billing/paywall route if the settings screen becomes too crowded

Error Handling

Purchase success but backend not updated yet

  • show a pending success state
  • refetch organization billing state for a short bounded window
  • if still unpaid, show a “purchase received, still syncing” message and expose restore/retry

Restore on different xtablo login

  • do not silently transfer entitlement across users
  • only associate restored state when RevenueCat identity maps to the authenticated xtablo user
  • otherwise fail closed and route to support/manual resolution

Existing paid web subscriber on iOS

  • do not offer Apple purchase
  • show current access status only

Non-owner unpaid member on iOS

  • do not offer purchase
  • show explanatory copy that billing is managed by the organization owner

Organization requires team but iOS v1 cannot sell it

  • do not offer a misleading in-app purchase CTA
  • show that the organization needs the team plan
  • direct the user to web billing or the billing owner as appropriate

Apple cancellation/refund/revoke

  • maintain access until normalized entitlement expiry when appropriate
  • drop access once the normalized state is no longer valid

Offline after purchase

  • never unlock from local callback alone
  • show syncing/reconnect guidance until the server state can be refreshed

RevenueCat or App Store outage

  • preserve existing access from last known Supabase state
  • fail new purchases and restores closed
  • show a retry message rather than changing billing state locally

Testing Strategy

Backend

  • unit tests for Apple product-to-plan normalization
  • unit tests for combined Stripe/Apple winner selection
  • route tests for RevenueCat webhook idempotency
  • route tests for malformed or unauthorized webhook rejection

Supabase

  • migration tests for table constraints and uniqueness
  • SQL or integration checks for owner-user lookup and entitlement filtering

Expo

  • unpaid owner sees paywall
  • paid owner does not see paywall
  • unpaid non-owner does not see purchase CTA
  • purchase success enters pending-sync state
  • restore purchase path refreshes billing state correctly

Rollout Notes

  • create App Store Connect subscription products for iOS solo and annual
  • configure RevenueCat offerings and map them explicitly in backend config
  • use an Expo development/custom build for local testing because Expo Go is insufficient for native IAP
  • keep sandbox and production Apple environments distinct in persisted records and operational logging

Implementation Boundaries

In scope

  • RevenueCat integration in xtablo-expo
  • iOS post-login paywall
  • owner-only purchase gating
  • RevenueCat webhook ingestion in apps/api
  • Apple billing tables and migrations in Supabase
  • unified billing resolution in getOrganizationBillingState

Out of scope

  • iOS team purchase flow
  • Stripe-to-Apple migration
  • Android billing
  • cross-account entitlement transfer tooling
  • direct client-side unlock without backend normalization

Likely File Touch Points

  • xtablo-expo/app/(app)/(tabs)/settings.tsx
  • xtablo-expo/stores/auth.tsx
  • new Expo billing hooks/components
  • apps/api/src/helpers/billing.ts
  • new apps/api/src/routers/revenuecat.ts or apple-billing.ts
  • apps/api/src/routers/index.ts
  • new Supabase migrations for Apple billing tables