mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
294 lines
9.9 KiB
Markdown
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
|
|
```
|