diff --git a/docs/superpowers/specs/2026-05-02-expo-apple-iap-unified-billing-design.md b/docs/superpowers/specs/2026-05-02-expo-apple-iap-unified-billing-design.md new file mode 100644 index 0000000..dcb5d3d --- /dev/null +++ b/docs/superpowers/specs/2026-05-02-expo-apple-iap-unified-billing-design.md @@ -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