Files
Sauci/docs/subscription-system.md
T

294 lines
9.9 KiB
Markdown

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