8.6 KiB
8.6 KiB
Stripe Integration Architecture
🏗️ Simplified Webhook-Based Architecture
Overview
This integration uses a pure webhook-based approach without Foreign Data Wrappers:
┌─────────────┐
│ Stripe │ (Source of truth)
└──────┬──────┘
│ Webhooks
↓
┌─────────────┐
│ Node.js │ (Webhook processor)
│ API │
└──────┬──────┘
│ SQL Functions
↓
┌─────────────┐
│ Supabase │ (Data storage)
│ Database │
└──────┬──────┘
│ Direct queries (RLS)
↓
┌─────────────┐
│ Frontend │ (React app)
│ (Vite) │
└─────────────┘
📊 Data Flow Patterns
Read Pattern (Fast ⚡)
Frontend → Supabase Client → RLS Policies → Database Tables
Examples:
- Check if user is paying:
user.is_paying - Get subscription:
useSubscription()→ queriesstripe_subscriptions - Get prices:
useStripePrices()→ queriesstripe_prices
Benefits:
- No API latency
- Real-time capable
- Secure via RLS
- Automatic caching
Write Pattern (via Stripe)
Frontend → API → Stripe → Webhook → Database
↓
(Direct update for instant feedback)
Examples:
- Create subscription → API creates checkout → Stripe processes → Webhook syncs
- Cancel subscription → API updates Stripe → immediately updates DB → Webhook confirms
- Manage subscription → Opens Stripe portal → Stripe handles → Webhook syncs
🗄️ Database Schema
Tables
stripe_customers
- id (PK)
- user_id (FK → auth.users)
- stripe_customer_id (unique)
- email
stripe_subscriptions
- id (PK)
- user_id (FK → auth.users)
- stripe_customer_id (FK)
- status (active, trialing, canceled, etc.)
- price_id
- current_period_start
- current_period_end
- cancel_at_period_end
stripe_products
- id (PK)
- name ("Standard")
- active
- description
stripe_prices
- id (PK)
- product_id (FK)
- unit_amount (in cents)
- currency
- interval (month, year)
profiles (extended)
- is_paying (boolean) ← Auto-updated by trigger
- subscription_tier (text) ← Auto-updated by trigger
RLS Policies
-- Users see only their own data
stripe_customers: WHERE auth.uid() = user_id
stripe_subscriptions: WHERE auth.uid() = user_id
-- Products/prices are public (for pricing page)
stripe_products: WHERE true
stripe_prices: WHERE true
Automatic Triggers
-- When subscription changes → Update profile
CREATE TRIGGER update_profile_on_subscription_change
AFTER INSERT OR UPDATE ON stripe_subscriptions
→ Updates profiles.is_paying and profiles.subscription_tier
🔄 Webhook Flow
1. Stripe Event Occurs
User subscribes → Stripe creates subscription
2. Webhook Sent
POST https://your-api.com/api/v1/stripe/webhook
Headers: stripe-signature
Body: {
type: "customer.subscription.created",
data: { object: { ...subscription } }
}
3. API Processes Event
// stripe-webhook.ts
1. Verify signature
2. Parse event type
3. Call appropriate database function
4. Database Function Executes
-- handle_stripe_subscription_upsert()
1. Upsert subscription record
2. Trigger fires automatically
3. Profile updated (is_paying = true)
5. Frontend Sees Update
// Next time query runs
const { data: subscription } = useSubscription();
// Returns updated data with RLS filtering
🔐 Security Model
Frontend → Database
Supabase Client (anon key)
→ RLS Policies
→ Only own user_id data
→ Read-only access
Frontend CANNOT:
- ❌ Modify subscription data
- ❌ See other users' subscriptions
- ❌ Delete customer records
Frontend CAN:
- ✅ Read own subscription
- ✅ Read public products/prices
- ✅ Call API endpoints for actions
API → Database
Supabase Client (service role key)
→ Full database access
→ Used only in webhook handlers
→ Validates Stripe signature first
API CAN:
- ✅ Insert/update via webhook functions
- ✅ Query any subscription for operations
- ✅ Execute privileged database functions
Webhook → Database
Stripe Webhook
→ Signature verified
→ Service role functions
→ Bypass RLS for writes
🎯 Why This Architecture?
No Foreign Data Wrappers
Reasons:
- ❌ Extra complexity
- ❌ Requires Vault configuration
- ❌ API rate limits apply
- ❌ Slower than cached data
- ✅ Webhooks provide all needed data
Direct Supabase Access
Benefits:
- ⚡ Performance: No API hop for reads
- 🔒 Security: RLS enforces data access
- 🎯 Simplicity: Standard Supabase patterns
- 📊 Real-time: Can use Supabase subscriptions
- 🔌 Offline: Can cache subscription data
API for Actions Only
Why:
- 🔑 Secret management: API has Stripe secret key
- ✅ Validation: Server-side validation before Stripe calls
- 🎫 Checkout: Creates secure checkout sessions
- 🔐 Signatures: Verifies webhook signatures
📈 Performance Characteristics
Query Performance
Check if user is paying:
user.is_paying // Instant (already in memory)
Get subscription details:
useSubscription()
// ~50-100ms (Supabase query with join)
// Cached for 5 minutes
Start checkout:
useCreateCheckoutSession()
// ~500-1000ms (API → Stripe → Redirect)
Webhook Processing
Typical webhook flow:
Stripe event → API receives → DB function → Trigger
Total: ~100-300ms
Database trigger:
Subscription updated → Profile updated
Total: ~10-50ms
🎨 Frontend Usage Patterns
Pattern 1: Simple Payment Check
const user = useUser();
if (!user.is_paying) {
return <PaywallComponent />;
}
return <PremiumFeature />;
Pattern 2: Detailed Subscription Info
const { data: subscription, isLoading } = useSubscription();
if (isLoading) return <Spinner />;
if (!subscription) return <UpgradePrompt />;
return (
<div>
Status: {subscription.status}
Renews: {subscription.current_period_end}
{subscription.cancel_at_period_end && <CancellationWarning />}
</div>
);
Pattern 3: Pricing Page
const { data: prices } = useStripePrices();
const { mutate: checkout } = useCreateCheckoutSession();
return prices?.map(price => (
<PriceCard
key={price.id}
amount={price.unit_amount}
interval={price.interval}
onSelect={() => checkout({ priceId: price.id })}
/>
));
🔧 Maintenance
Syncing Products/Prices
Products and prices are synced automatically via webhooks when you:
- Create/update product in Stripe Dashboard
- Webhook fires → Syncs to
stripe_productstable - Frontend queries updated pricing
Monitoring Subscriptions
-- View current user's active subscription (secure, RLS-compliant)
SELECT * FROM get_my_active_subscription();
-- Check for subscription status distribution (requires direct stripe schema access)
SELECT
status::text,
COUNT(*) as count
FROM stripe.subscriptions
GROUP BY status;
-- Find subscriptions ending soon (admin query)
SELECT
p.email,
si.current_period_end
FROM stripe.subscriptions s
JOIN stripe.customers c ON c.id = s.customer
JOIN stripe.subscription_items si ON si.subscription = s.id
JOIN profiles p ON p.id = (c.metadata->>'user_id')::uuid
WHERE to_timestamp(si.current_period_end) < NOW() + INTERVAL '7 days'
AND s.status::text = 'active';
📝 Summary
This architecture provides:
- ✅ Simple: No wrappers, just webhooks + RLS
- ✅ Fast: Direct Supabase queries for reads
- ✅ Secure: RLS + signature verification
- ✅ Maintainable: Standard patterns
- ✅ Scalable: Handles thousands of subscriptions
- ✅ Cost-effective: Fewer API calls
- ✅ Developer-friendly: TypeScript + React hooks
Trade-offs:
- ⏱️ Eventual consistency (webhook delay ~1-5 seconds)
- 🔄 Requires webhook endpoint to be reliable
- 📦 More database tables vs API-only approach
Verdict: ✅ Optimal for SaaS apps with subscription model
Implementation Files:
- Database:
sql/35_stripe_wrappers.sql,sql/36_stripe_webhooks.sql - Backend:
api/src/stripe.ts,api/src/stripe-webhook.ts - Frontend:
apps/main/src/hooks/stripe.ts,apps/main/src/components/SubscriptionCard.tsx - Types:
packages/shared/src/types/stripe.types.ts