docs: add expo apple iap unified billing design
This commit is contained in:
parent
888bc269ba
commit
5fba42f678
1 changed files with 347 additions and 0 deletions
|
|
@ -0,0 +1,347 @@
|
|||
# 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
|
||||
Loading…
Reference in a new issue