376 lines
8.6 KiB
Markdown
376 lines
8.6 KiB
Markdown
|
|
# 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()` → queries `stripe_subscriptions`
|
||
|
|
- Get prices: `useStripePrices()` → queries `stripe_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**
|
||
|
|
```sql
|
||
|
|
- id (PK)
|
||
|
|
- user_id (FK → auth.users)
|
||
|
|
- stripe_customer_id (unique)
|
||
|
|
- email
|
||
|
|
```
|
||
|
|
|
||
|
|
**stripe_subscriptions**
|
||
|
|
```sql
|
||
|
|
- 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**
|
||
|
|
```sql
|
||
|
|
- id (PK)
|
||
|
|
- name ("Standard")
|
||
|
|
- active
|
||
|
|
- description
|
||
|
|
```
|
||
|
|
|
||
|
|
**stripe_prices**
|
||
|
|
```sql
|
||
|
|
- id (PK)
|
||
|
|
- product_id (FK)
|
||
|
|
- unit_amount (in cents)
|
||
|
|
- currency
|
||
|
|
- interval (month, year)
|
||
|
|
```
|
||
|
|
|
||
|
|
**profiles** (extended)
|
||
|
|
```sql
|
||
|
|
- is_paying (boolean) ← Auto-updated by trigger
|
||
|
|
- subscription_tier (text) ← Auto-updated by trigger
|
||
|
|
```
|
||
|
|
|
||
|
|
### RLS Policies
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- 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
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- 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
|
||
|
|
```typescript
|
||
|
|
// stripe-webhook.ts
|
||
|
|
1. Verify signature
|
||
|
|
2. Parse event type
|
||
|
|
3. Call appropriate database function
|
||
|
|
```
|
||
|
|
|
||
|
|
### 4. Database Function Executes
|
||
|
|
```sql
|
||
|
|
-- handle_stripe_subscription_upsert()
|
||
|
|
1. Upsert subscription record
|
||
|
|
2. Trigger fires automatically
|
||
|
|
3. Profile updated (is_paying = true)
|
||
|
|
```
|
||
|
|
|
||
|
|
### 5. Frontend Sees Update
|
||
|
|
```typescript
|
||
|
|
// 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:**
|
||
|
|
```typescript
|
||
|
|
user.is_paying // Instant (already in memory)
|
||
|
|
```
|
||
|
|
|
||
|
|
**Get subscription details:**
|
||
|
|
```typescript
|
||
|
|
useSubscription()
|
||
|
|
// ~50-100ms (Supabase query with join)
|
||
|
|
// Cached for 5 minutes
|
||
|
|
```
|
||
|
|
|
||
|
|
**Start checkout:**
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
```typescript
|
||
|
|
const user = useUser();
|
||
|
|
if (!user.is_paying) {
|
||
|
|
return <PaywallComponent />;
|
||
|
|
}
|
||
|
|
return <PremiumFeature />;
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pattern 2: Detailed Subscription Info
|
||
|
|
```typescript
|
||
|
|
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
|
||
|
|
```typescript
|
||
|
|
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:
|
||
|
|
1. Create/update product in Stripe Dashboard
|
||
|
|
2. Webhook fires → Syncs to `stripe_products` table
|
||
|
|
3. Frontend queries updated pricing
|
||
|
|
|
||
|
|
### Monitoring Subscriptions
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- 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:**
|
||
|
|
|
||
|
|
1. ✅ **Simple**: No wrappers, just webhooks + RLS
|
||
|
|
2. ✅ **Fast**: Direct Supabase queries for reads
|
||
|
|
3. ✅ **Secure**: RLS + signature verification
|
||
|
|
4. ✅ **Maintainable**: Standard patterns
|
||
|
|
5. ✅ **Scalable**: Handles thousands of subscriptions
|
||
|
|
6. ✅ **Cost-effective**: Fewer API calls
|
||
|
|
7. ✅ **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`
|
||
|
|
|