# 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 Apple’s purchase flow.
- Expo’s 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:
- Apple App Review Guidelines: https://developer.apple.com/app-store/review/guidelines/
- Expo in-app purchases guide, updated March 9, 2026: https://docs.expo.dev/guides/in-app-purchases/

## 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
