xtablo-source/docs/STRIPE_ARCHITECTURE.md

376 lines
8.6 KiB
Markdown
Raw Permalink Normal View History

2025-11-03 08:46:10 +00:00
# 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`