# Subscription & Premium System This document explains how subscriptions and premium access work across the client, server, and database layers. ## Overview Sauci uses RevenueCat for subscription management with a multi-layer architecture: ``` ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ Mobile App │────▶│ RevenueCat │────▶│ Supabase Edge │ │ (RevenueCat │ │ Service │ │ Functions │ │ SDK) │◀────│ │◀────│ │ └─────────────────┘ └──────────────────┘ └─────────────────┘ │ ▼ ┌─────────────────┐ │ Database │ │ (is_premium) │ └─────────────────┘ ``` ## Components ### 1. Client SDK (`src/lib/revenuecat.ts`) The `RevenueCatService` class wraps the `react-native-purchases` SDK. **Platform Support:** - iOS: Full support with native SDK - Android: Full support with native SDK. `getApiKey()` selects the Android key, `com.android.vending.BILLING` is declared in `app.json`, and the paywall has no platform gating. The backend is store-agnostic: the webhook passes `event.store` through unchanged, so `PLAY_STORE` events need no separate handling. - Web/Expo Go: Gracefully disabled (returns defaults) **Key Methods:** | Method | Purpose | |--------|---------| | `initialize(userId)` | Configure SDK with API key and user ID | | `login(userId)` | Associate purchases with Supabase user | | `getOfferings()` | Fetch available subscription packages | | `purchasePackage(pkg)` | Initiate purchase flow | | `restorePurchases()` | Restore previous purchases | | `getCustomerInfo()` | Get current subscription status | | `parseCustomerInfo()` | Convert to `SubscriptionState` | **Entitlement Check:** ```typescript const entitlement = customerInfo.entitlements.active[ENTITLEMENT_ID]; return { isProUser: !!entitlement, activeSubscription: entitlement?.productIdentifier || null, expirationDate: entitlement?.expirationDate ? new Date(...) : null, willRenew: entitlement?.willRenew || false, }; ``` ### 2. Zustand Store (`src/store/index.ts`) The `useSubscriptionStore` manages subscription state in the app: ```typescript interface SubscriptionStoreState { subscription: SubscriptionState; offerings: PurchasesOffering | null; isLoadingOfferings: boolean; isPurchasing: boolean; error: string | null; } ``` **Key Actions:** - `initializeRevenueCat(userId)` - Called after auth, sets up SDK - `fetchOfferings()` - Loads available packages for paywall - `purchasePackage(pkg)` - Handles purchase with error handling - `restorePurchases()` - Restores and syncs to server - `refreshSubscriptionStatus()` - Checks RevenueCat and syncs to server ### 3. Webhook Handler (`functions/revenuecat-webhook`) RevenueCat sends webhook events for subscription lifecycle changes. **Event Types Handled:** | Event | Status Set | |-------|------------| | `INITIAL_PURCHASE` | `active` | | `RENEWAL` | `active` | | `UNCANCELLATION` | `active` | | `PRODUCT_CHANGE` | `active` | | `CANCELLATION` | `cancelled` | | `EXPIRATION` | `expired` | | `BILLING_ISSUE` | `billing_issue` | | `SUBSCRIPTION_PAUSED` | `paused` | **Idempotency:** Events are logged to `revenuecat_webhook_events` table with unique `event_id` to prevent duplicate processing. **Flow:** ``` 1. Verify webhook secret (Authorization header) 2. Check for duplicate event_id 3. Record event in webhook_events table 4. Upsert subscription record 5. Database trigger syncs is_premium to profiles ``` ### 4. Sync Function (`functions/sync-subscription`) Called from client to verify subscription status server-side. **When Called:** - After purchase completes - On app foreground (if status differs) - Manual restore purchases **Flow:** ``` 1. Verify user JWT 2. Call RevenueCat API: GET /v1/subscribers/{user_id} 3. Check entitlement expiration date 4. Update profiles.is_premium ``` ### 5. Database Layer **Tables:** ```sql -- Main subscription record subscriptions ( user_id UUID FK profiles, product_id TEXT, status subscription_status, -- active/cancelled/expired/billing_issue/paused expires_at TIMESTAMPTZ, ... ) -- Idempotency tracking revenuecat_webhook_events ( event_id TEXT UNIQUE, event_type TEXT, app_user_id TEXT, payload JSONB ) ``` **Auto-Sync Trigger:** ```sql CREATE TRIGGER sync_premium_on_subscription_change AFTER INSERT OR UPDATE ON public.subscriptions FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status(); ``` This trigger updates `profiles.is_premium` based on active subscription status. ## Premium Access Logic ### Partner Premium Sharing If either partner in a couple has premium, both get access: ```sql CREATE FUNCTION public.has_premium_access(check_user_id UUID) RETURNS BOOLEAN AS $$ -- Returns TRUE if user OR their partner has is_premium = TRUE $$; ``` ### RLS Policies Premium packs are filtered at the database level: ```sql CREATE POLICY "Users or partners with premium can view premium packs" ON public.question_packs FOR SELECT USING ( is_premium = FALSE OR EXISTS ( SELECT 1 FROM profiles p WHERE p.id = auth.uid() AND (p.is_premium = TRUE OR EXISTS ( SELECT 1 FROM profiles partner WHERE partner.couple_id = p.couple_id AND partner.is_premium = TRUE )) ) ); ``` ### Question Filtering The `get_recommended_questions()` function also filters premium packs: ```sql -- Only include packs where: (qp.is_premium = FALSE OR v_has_premium = TRUE) ``` ## Environment Variables | Variable | Location | Purpose | |----------|----------|---------| | `EXPO_PUBLIC_REVENUECAT_IOS_API_KEY` | Mobile app | RevenueCat iOS public SDK key | | `EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY` | Mobile app | RevenueCat Android public SDK key | | `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` | Mobile app | Entitlement identifier (code default: `"pro"`; builds set `"Sauci Pro"` — see warning below) | | `REVENUECAT_API_KEY` | Edge functions | RevenueCat secret API key | | `REVENUECAT_ENTITLEMENT_ID` | Edge functions | Server-side entitlement check | | `REVENUECAT_WEBHOOK_SECRET` | Edge functions | Webhook auth verification | > **Entitlement ID mismatch risk.** The fallback in `src/lib/revenuecat.ts` is > `"pro"`, but `eas.json` sets `EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID` to > `"Sauci Pro"`. If that variable is missing from a build profile, the SDK looks up > an entitlement that does not exist, `parseCustomerInfo()` returns > `isProUser: false`, and paying users silently appear unsubscribed with no error. > Confirm the variable is set on every iOS and Android build profile. Production OTA publishes also fail before upload unless an isolated compiled Expo bundle contains both platform public SDK keys and this entitlement identifier. Run `npm run ota:preflight:production` from `apps/mobile`; it validates presence only and never prints those values. ## Subscription Flow Diagrams ### Purchase Flow ``` User taps Subscribe │ ▼ ┌───────────────────┐ │ fetchOfferings() │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ Display Paywall │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ purchasePackage() │──▶ Native App Store UI └─────────┬─────────┘ ▼ ┌───────────────────┐ │ RevenueCat SDK │──▶ Sends webhook to server │ returns success │ └─────────┬─────────┘ ▼ ┌───────────────────┐ │ refreshStatus() │──▶ Calls sync-subscription └─────────┬─────────┘ ▼ ┌───────────────────┐ │ fetchUser() │──▶ UI shows premium features └───────────────────┘ ``` ### Webhook Flow ``` RevenueCat Event (e.g., RENEWAL) │ ▼ ┌───────────────────────┐ │ revenuecat-webhook │ │ Edge Function │ └─────────┬─────────────┘ │ ├──▶ Check event_id (idempotency) │ ├──▶ Record in webhook_events │ ▼ ┌───────────────────────┐ │ UPSERT subscriptions │ └─────────┬─────────────┘ │ ▼ ┌───────────────────────┐ │ sync_premium_status() │──▶ Updates profiles.is_premium │ (Database Trigger) │ └───────────────────────┘ ``` ## Testing RevenueCat sandbox mode is detected via `event.environment !== "PRODUCTION"` and stored in `subscriptions.is_sandbox`. To test webhooks locally: ```bash # Use ngrok or similar to expose local Supabase supabase functions serve revenuecat-webhook --env-file .env.local ```