9.9 KiB
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.BILLINGis declared inapp.json, and the paywall has no platform gating. The backend is store-agnostic: the webhook passesevent.storethrough unchanged, soPLAY_STOREevents 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:
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:
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
}
Key Actions:
initializeRevenueCat(userId)- Called after auth, sets up SDKfetchOfferings()- Loads available packages for paywallpurchasePackage(pkg)- Handles purchase with error handlingrestorePurchases()- Restores and syncs to serverrefreshSubscriptionStatus()- 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:
-- 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:
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:
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:
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:
-- 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.tsis"pro", buteas.jsonsetsEXPO_PUBLIC_REVENUECAT_ENTITLEMENT_IDto"Sauci Pro". If that variable is missing from a build profile, the SDK looks up an entitlement that does not exist,parseCustomerInfo()returnsisProUser: 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:
# Use ngrok or similar to expose local Supabase
supabase functions serve revenuecat-webhook --env-file .env.local