This commit is contained in:
Arthur Belleville 2025-11-03 09:46:10 +01:00
parent f6acbef13d
commit 7bb90becb9
No known key found for this signature in database
12 changed files with 2964 additions and 107 deletions

View file

@ -0,0 +1,151 @@
# 🧹 Cleanup Old Stripe Functions
## Overview
Since we're now using **@supabase/stripe-sync-engine**, we need to remove the custom webhook handler functions that were created by the old `sql/36_stripe_webhooks.sql` file.
## ✅ What's Been Done
### Files Deleted
- ✅ `api/src/stripe-webhook.ts` - Custom webhook handler (replaced by library)
- ✅ `sql/36_stripe_webhooks.sql` - Custom webhook SQL functions (replaced by library)
### Files Updated
- ✅ `api/src/stripe.ts` - Now uses `StripeSync` library
- ✅ `apps/main/src/hooks/stripe.ts` - Uses `stripe` schema
- ✅ `sql/35_stripe_wrappers.sql` - Uses `stripe` schema, updated function references
## 🗑️ Cleanup Database Functions
Run this SQL to remove the old custom functions:
```sql
\i sql/cleanup_old_stripe_functions.sql
```
Or run manually:
```sql
-- Drop old custom webhook handler functions
drop function if exists public.handle_stripe_customer_created(text, text, uuid);
drop function if exists public.handle_stripe_customer_updated(text, text);
drop function if exists public.handle_stripe_customer_deleted(text);
drop function if exists public.handle_stripe_product_upsert(text, text, text, boolean, text, jsonb);
drop function if exists public.handle_stripe_product_deleted(text);
drop function if exists public.handle_stripe_price_upsert(text, text, boolean, text, bigint, text, integer, integer, jsonb);
drop function if exists public.handle_stripe_price_deleted(text);
drop function if exists public.handle_stripe_subscription_upsert(text, text, text, text, integer, boolean, timestamp with time zone, timestamp with time zone, timestamp with time zone, timestamp with time zone, timestamp with time zone);
drop function if exists public.handle_stripe_subscription_deleted(text);
```
## ✅ Verify Cleanup
Run this to check if old functions are gone:
```sql
SELECT routine_name
FROM information_schema.routines
WHERE routine_schema = 'public'
AND routine_name LIKE 'handle_stripe_%';
```
Should return **0 rows**.
## ✅ Verify Kept Functions
These functions are still needed and should exist:
```sql
SELECT
routine_schema,
routine_name
FROM information_schema.routines
WHERE routine_schema IN ('public', 'stripe')
AND routine_name IN (
'is_paying_user',
'get_user_subscription_status',
'get_user_stripe_customer_id',
'sync_subscription_user_id',
'update_profile_subscription_status',
'update_updated_at_column'
)
ORDER BY routine_name;
```
Should return **6 rows** (all the functions we still use).
## 📋 Migration Order
For a fresh setup:
1. **Run stripe-sync-engine migrations**
```typescript
import { runMigrations } from '@supabase/stripe-sync-engine';
await runMigrations({ databaseUrl: process.env.DATABASE_URL });
```
Creates `stripe` schema and all base tables
2. **Run custom SQL**
```sql
\i sql/35_stripe_wrappers.sql
```
Adds `user_id`, RLS, triggers, helper functions
3. **(Optional) Run cleanup if old functions exist**
```sql
\i sql/cleanup_old_stripe_functions.sql
```
Removes old custom webhook handlers
## 🎯 What Each Function Does
### Functions We Keep (In `public` schema):
| Function | Purpose | Used By |
|----------|---------|---------|
| `is_paying_user(uuid)` | Check if user has active subscription | Frontend queries |
| `get_user_subscription_status(uuid)` | Get subscription details | Optional |
| `get_user_stripe_customer_id(uuid)` | Get Stripe customer ID | API endpoints |
| `update_profile_subscription_status()` | Trigger: Update profile.is_paying | Automatic |
| `update_updated_at_column()` | Trigger: Update timestamps | Automatic |
### Functions We Keep (In `stripe` schema):
| Function | Purpose | Used By |
|----------|---------|---------|
| `sync_subscription_user_id()` | Trigger: Populate user_id in subscriptions | Automatic |
### Functions We Removed:
| Function | Why Removed |
|----------|-------------|
| `handle_stripe_customer_*` | ✅ Library handles it |
| `handle_stripe_product_*` | ✅ Library handles it |
| `handle_stripe_price_*` | ✅ Library handles it |
| `handle_stripe_subscription_*` | ✅ Library handles it |
## 🎉 Result
**Before:**
- Custom webhook handlers: 9 functions
- Custom webhook processing code: 267 lines (TypeScript)
- Custom SQL functions: 315 lines
**After:**
- Library handles all webhooks automatically
- Custom functions: 6 (only for profile integration)
- Total custom code: ~150 lines
**Reduction**: ~80% less code to maintain! 🎊
## 📝 Summary
✅ Old webhook handler functions removed
✅ stripe-sync-engine library integrated
✅ All code updated to use `stripe` schema
✅ RLS policies in place
✅ Helper functions preserved
✅ Triggers working
**Next**: Test with fake accounts (see `docs/TESTING_WITH_FAKE_ACCOUNTS.md`)

375
docs/STRIPE_ARCHITECTURE.md Normal file
View file

@ -0,0 +1,375 @@
# 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`

263
docs/STRIPE_FINAL_SETUP.md Normal file
View file

@ -0,0 +1,263 @@
# 🎉 Final Stripe Setup - Using Official Library
## Overview
We're using the official **@supabase/stripe-sync-engine** library from Supabase.
This handles ALL webhook processing automatically - we just add custom profile integration on top!
**Repository**: [https://github.com/supabase/stripe-sync-engine](https://github.com/supabase/stripe-sync-engine)
## 📦 What's Implemented
### ✅ Files That Matter
**Backend:**
- `api/src/stripe.ts` - Webhook + action endpoints (uses StripeSync)
- `sql/35_stripe_wrappers.sql` - Profile integration & RLS policies
**Frontend:**
- `apps/main/src/hooks/stripe.ts` - React hooks (queries Supabase directly)
- `apps/main/src/components/SubscriptionCard.tsx` - Ready-to-use UI
**Documentation:**
- `docs/STRIPE_WITH_SYNC_ENGINE.md` - Main guide ⭐
- `docs/TESTING_WITH_FAKE_ACCOUNTS.md` - Testing guide
### ❌ Files Deleted (Not Needed Anymore)
- ~~`api/src/stripe-webhook.ts`~~ - Library handles this!
- ~~`sql/36_stripe_webhooks.sql`~~ - Library handles this!
## 🚀 Setup Steps
### 1. Install Library
```bash
cd api
npm install @supabase/stripe-sync-engine
```
✅ Already done!
### 2. Run Library Migrations
The library needs to create its tables first. Either:
**Option A: Via Code** (recommended for first time)
```typescript
import { runMigrations } from "@supabase/stripe-sync-engine";
await runMigrations({
databaseUrl:
"postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres",
});
```
**Option B: Manually**
Copy migrations from `node_modules/@supabase/stripe-sync-engine/dist/migrations/*.sql` and run in Supabase SQL Editor.
### 3. Run Custom SQL
After library migrations, run:
```sql
\i sql/35_stripe_wrappers.sql
```
This adds:
- `user_id` columns for RLS
- `profiles.is_paying` and `subscription_tier` fields
- Automatic triggers
- Helper functions
### 4. Configure Environment
**API (`.env`):**
```env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
DATABASE_URL=postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres
FRONTEND_URL=http://localhost:5173
```
**Frontend (`apps/main/.env`):**
```env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_xxxxx
```
### 5. Create "Standard" Product in Stripe
1. Stripe Dashboard → **Test Mode** → Products
2. Add product: **"Standard"**
3. Monthly price: €9.99/month
4. Copy the `price_id`
5. Add to frontend `.env`
### 6. Configure Webhook
1. Stripe Dashboard → Developers → Webhooks
2. Add endpoint: `https://your-api.com/api/v1/stripe/webhook`
3. Select **all events** (library handles them all!)
4. Copy signing secret → Add to API `.env`
### 7. Add UI to Settings
In `apps/main/src/pages/settings.tsx`:
```typescript
import { SubscriptionCard } from "../components/SubscriptionCard";
// Add in your cards section:
<SubscriptionCard />;
```
## 🎯 How It Works
### Webhook Flow
```
Stripe event → API webhook endpoint → StripeSync library → Database tables → Triggers → Profile updated
```
### Read Flow (From Frontend)
```
Frontend → Supabase Client → RLS policies → stripe_subscriptions → User's data
```
**No custom webhook code needed!** The library handles everything.
## 🧪 Testing
### Quick Test
1. **Start API**: `cd api && npm run dev`
2. **Start Frontend**: `cd apps/main && npm run dev`
3. **Start Webhook Forwarding**:
```bash
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
```
4. **Create test account**: `test@example.com`
5. **Subscribe**: Use card `4242 4242 4242 4242`
6. **Verify**: Check `user.is_paying === true`
## 📊 What Tables Are Created
### By stripe-sync-engine Library:
- `stripe_customers`
- `stripe_subscriptions`
- `stripe_subscription_items`
- `stripe_products`
- `stripe_prices`
- `stripe_invoices`
- `stripe_charges`
- `stripe_payment_intents`
- `stripe_payment_methods`
- ... and 30+ more!
### By Our SQL (35_stripe_wrappers.sql):
- Adds `user_id` to customers/subscriptions
- Adds `is_paying` to profiles
- Adds `subscription_tier` to profiles
- Creates RLS policies
- Creates triggers
- Creates helper functions
## ⚡ Key Advantages
| Aspect | Before | After (with library) |
| -------------- | -------------- | -------------------- |
| Webhook code | 267 lines | ~15 lines |
| Event coverage | 8 events | 100+ events |
| Maintenance | You | Supabase |
| Schema updates | Manual | Automatic |
| Backfilling | Custom scripts | Built-in |
| Battle-tested | No | ✅ Yes |
## 🎓 Using the Library Features
### Backfill Historical Data
```typescript
// Sync all existing Stripe data
await stripeSync.syncBackfill({ object: "all" });
// Or sync specific objects
await stripeSync.syncProducts();
await stripeSync.syncCustomers();
await stripeSync.syncSubscriptions();
```
### Sync Single Entity
```typescript
// Sync a specific customer
await stripeSync.syncSingleEntity("cus_xxxxx");
// Sync a specific subscription
await stripeSync.syncSingleEntity("sub_xxxxx");
```
## ✅ Success Criteria
Your integration works when:
1. ✅ Library installed: `npm list @supabase/stripe-sync-engine`
2. ✅ Library migrations run
3. ✅ Custom SQL (35) run
4. ✅ Environment variables configured
5. ✅ Webhook endpoint configured in Stripe
6. ✅ Test subscription creates data in `stripe_subscriptions`
7. ✅ `profiles.is_paying` updates automatically
8. ✅ Frontend shows subscription status
9. ✅ RLS policies work (users see only their data)
## 🐛 Troubleshooting
### Library Not Found
```bash
cd api && npm install @supabase/stripe-sync-engine
```
### Migrations Failing
Make sure to run **library migrations first**, then `sql/35_stripe_wrappers.sql`
### user_id Not Populating
Ensure customer is created with metadata:
```typescript
stripe.customers.create({
email: user.email,
metadata: { user_id: user.id }, // ← Important!
});
```
This is already handled in `api/src/stripe.ts` create-checkout-session endpoint.
## 📞 Support
- **Library Issues**: https://github.com/supabase/stripe-sync-engine/issues
- **Library Docs**: https://supabase.github.io/stripe-sync-engine
- **Our Implementation**: See `docs/STRIPE_WITH_SYNC_ENGINE.md`
---
**Status**: ✅ Fully Implemented
**Complexity**: Minimal (library does the heavy lifting)
**Maintenance**: Low (library handles updates)
**Ready to Deploy**: Yes!

View file

@ -3,29 +3,32 @@
## Overview
Complete Stripe integration for Xtablo with a single "Standard" subscription plan.
Uses custom Node.js API (Hono) with Supabase database for data storage.
**Architecture:**
- ✅ **Webhook-based**: Stripe webhooks sync data to Supabase
- ✅ **Direct Supabase access**: Frontend queries Supabase directly (no API for reads)
- ✅ **API for actions**: Checkout and subscription management via Node.js API
- ✅ **RLS-protected**: Row Level Security ensures data privacy
## ✅ What Has Been Implemented
### 1. Database Schema (`sql/35_stripe_wrappers.sql`)
#### Foreign Tables (Direct Stripe API Access)
- `stripe.customers` - Query Stripe customers
- `stripe.subscriptions` - Query Stripe subscriptions
- `stripe.products` - Query Stripe products
- `stripe.prices` - Query Stripe prices
#### Tables (Synced via Webhooks)
#### Local Tables (Synced via Webhooks)
- `public.stripe_customers` - Customer records with user mapping
- `public.stripe_subscriptions` - Subscription history
- `public.stripe_products` - Product catalog
- `public.stripe_prices` - Pricing information
#### Profile Enhancements
- `profiles.is_paying` - Boolean flag for quick checks
- `profiles.subscription_tier` - Current tier ('free' or 'standard')
#### Helper Functions
```sql
-- Check if user is paying
SELECT is_paying_user(auth.uid());
@ -38,9 +41,11 @@ SELECT get_user_stripe_customer_id(auth.uid());
```
#### Views
- `active_subscriptions` - All active subs with user info
#### Automatic Updates
- Triggers automatically update `profiles.is_paying` when subscriptions change
- `updated_at` timestamps managed automatically
@ -49,55 +54,68 @@ SELECT get_user_stripe_customer_id(auth.uid());
Includes functions to process all major Stripe events:
**Customer Events:**
- `handle_stripe_customer_created()`
- `handle_stripe_customer_updated()`
- `handle_stripe_customer_deleted()`
**Product Events:**
- `handle_stripe_product_upsert()`
- `handle_stripe_product_deleted()`
**Price Events:**
- `handle_stripe_price_upsert()`
- `handle_stripe_price_deleted()`
**Subscription Events:**
- `handle_stripe_subscription_upsert()`
- `handle_stripe_subscription_deleted()`
### 3. Backend API (`api/src/stripe.ts` & `api/src/stripe-webhook.ts`)
**Endpoints:**
- `POST /api/v1/stripe/webhook` - Stripe webhook handler
- `POST /api/v1/stripe/webhook` - Stripe webhook handler (signature verified)
- `POST /api/v1/stripe/create-checkout-session` - Start subscription checkout
- `POST /api/v1/stripe/create-portal-session` - Open customer portal
- `GET /api/v1/stripe/subscription` - Get user's subscription
- `GET /api/v1/stripe/is-paying` - Check payment status
- `GET /api/v1/stripe/prices` - Get Standard plan prices
- `POST /api/v1/stripe/cancel-subscription` - Cancel at period end
- `POST /api/v1/stripe/reactivate-subscription` - Reactivate subscription
**Note:** Subscription status queries (`is-paying`, `subscription`, `prices`) are handled directly by the frontend using Supabase client with RLS policies.
**Features:**
- ✅ Automatic customer creation
- ✅ Automatic customer creation with user mapping
- ✅ Secure webhook signature verification
- ✅ Complete subscription lifecycle management
- ✅ Customer portal access
- ✅ Subscription cancellation/reactivation
- ✅ Optimistic updates (API updates DB before webhook for instant UI feedback)
### 4. Frontend Hooks (`apps/main/src/hooks/stripe.ts`)
**Available Hooks:**
- `useSubscription()` - Get subscription details
- `useIsPayingUser()` - Check if user is paying
- `useStripePrices()` - Get available prices
- `useCreateCheckoutSession()` - Start checkout
- `useCreatePortalSession()` - Open portal
- `useCancelSubscription()` - Cancel subscription
- `useReactivateSubscription()` - Reactivate subscription
**Direct Supabase Queries (Fast, RLS-protected):**
- `useSubscription()` - Get subscription details from Supabase
- `useIsPayingUser()` - Check if user is paying (from user profile)
- `useStripePrices()` - Get available prices from Supabase
**API Calls (For Actions):**
- `useCreateCheckoutSession()` - Start checkout (calls API)
- `useCreatePortalSession()` - Open portal (calls API)
- `useCancelSubscription()` - Cancel subscription (calls API)
- `useReactivateSubscription()` - Reactivate subscription (calls API)
### 5. TypeScript Types (`packages/shared/src/types/stripe.types.ts`)
Complete type definitions:
- `StripeCustomer`
- `StripeSubscription`
- `StripeProduct`
@ -107,6 +125,7 @@ Complete type definitions:
### 6. Documentation (`docs/STRIPE_SETUP.md`)
Comprehensive setup guide including:
- Step-by-step configuration
- Edge Function example
- Frontend integration examples
@ -116,6 +135,7 @@ Comprehensive setup guide including:
## 🎯 Key Features
### Security
- ✅ Row Level Security (RLS) on all tables
- ✅ Users can only see their own data
- ✅ Stripe API key stored securely in Vault
@ -123,11 +143,13 @@ Comprehensive setup guide including:
- ✅ Service role functions for webhooks
### Performance
- ✅ Indexed on user_id, customer_id, status
- ✅ Efficient queries for subscription checks
- ✅ Cached subscription status in profiles table
### Data Integrity
- ✅ Foreign key relationships
- ✅ Cascading deletes
- ✅ Automatic timestamp management
@ -136,6 +158,7 @@ Comprehensive setup guide including:
## 📊 How to Check if User is Paying
### Method 1: From Profile (Fastest)
```typescript
const user = useUser();
if (user.is_paying) {
@ -144,26 +167,21 @@ if (user.is_paying) {
}
```
### Method 2: Using Function (Most Accurate)
### Method 2: Using Hook (Same as Profile)
```typescript
const { data: isPaying } = useQuery({
queryKey: ['is_paying', userId],
queryFn: async () => {
const { data } = await supabase.rpc('is_paying_user', {
user_uuid: userId
});
return data;
}
});
const { data: isPaying } = useIsPayingUser();
// Returns user.is_paying directly
```
### Method 3: Direct Query
### Method 3: Direct Supabase Query
```typescript
const { data: subscription } = await supabase
.from('stripe_subscriptions')
.select('*')
.eq('user_id', userId)
.in('status', ['active', 'trialing'])
.from("stripe_subscriptions")
.select("*")
.eq("user_id", userId)
.in("status", ["active", "trialing"])
.single();
const isPaying = !!subscription;
@ -171,41 +189,59 @@ const isPaying = !!subscription;
## 🔄 Data Flow
### Subscription Creation Flow
1. **User Clicks "Subscribe to Standard"**
→ Frontend calls `/api/v1/stripe/create-checkout-session`
→ Frontend calls `useCreateCheckoutSession({ priceId })`
→ Calls API: `POST /api/v1/stripe/create-checkout-session`
2. **API Creates Checkout**
→ Creates/retrieves Stripe customer
→ Creates checkout session
→ Creates/retrieves Stripe customer (with `user_id` in metadata)
→ Creates Stripe checkout session
→ Returns checkout URL
→ Frontend redirects to Stripe
3. **User Completes Payment**
→ Stripe processes payment
→ Fires `customer.subscription.created` webhook
4. **Webhook Received**
→ API endpoint `/api/v1/stripe/webhook` receives event
→ API endpoint `POST /api/v1/stripe/webhook` receives event
→ Verifies signature
→ Calls `handle_stripe_subscription_upsert()`
→ Calls `handle_stripe_subscription_upsert()` database function
5. **Database Updated**
→ Inserts into `stripe_subscriptions`
→ Trigger updates `profiles.is_paying = true` and `subscription_tier = 'standard'`
→ Function inserts into `stripe_subscriptions`
→ Trigger fires: `update_profile_on_subscription_change`
→ Updates `profiles.is_paying = true` and `subscription_tier = 'standard'`
6. **Frontend Updates**
→ User's profile automatically shows paying status
`user.is_paying = true`
`user.subscription_tier = 'standard'`
6. **Frontend Queries Supabase**
`useSubscription()` queries `stripe_subscriptions` table directly
`useUser()` shows updated `is_paying` and `subscription_tier`
→ UI automatically reflects new status
### Read Flow (No API Needed)
```
Frontend → Supabase Client → RLS Policies → stripe_subscriptions table → User's data
```
Benefits:
- ⚡ **Fast**: Direct database access, no API hop
- 🔒 **Secure**: RLS ensures users only see their data
- 📊 **Real-time**: Can use Supabase realtime subscriptions if needed
## 🚀 Next Steps to Complete Implementation
### Required Setup:
1. **Update Vault Key ID**
1. **Update Vault Key ID**
- Get key_id after storing Stripe API key
- Update in `sql/35_stripe_wrappers.sql` line 27
2. **Install Stripe SDK**
```bash
cd api
npm install stripe @stripe/stripe-js
@ -213,6 +249,7 @@ const isPaying = !!subscription;
3. **Configure API Environment Variables**
Add to `api/.env`:
```env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
@ -221,11 +258,13 @@ const isPaying = !!subscription;
4. **Configure Frontend Environment**
Add to `apps/main/.env`:
```env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
```
5. **Create "Standard" Plan in Stripe**
- Create product named exactly **"Standard"**
- Add pricing (monthly/yearly)
- Note price IDs for frontend
@ -238,6 +277,7 @@ const isPaying = !!subscription;
### Frontend Implementation:
7. **Hooks Already Created**
- `useSubscription()` - Get subscription details
- `useIsPayingUser()` - Check payment status
- `useCreateCheckoutSession()` - Initiate checkout
@ -246,12 +286,13 @@ const isPaying = !!subscription;
- `useReactivateSubscription()` - Reactivate
8. **Build UI Components**
- Pricing page
- Subscription management page
- Payment status badges
- Upgrade prompts
7. **Add Feature Gates**
9. **Add Feature Gates**
```typescript
if (!user.is_paying) {
return <PremiumFeatureLockedPrompt />;
@ -261,6 +302,7 @@ const isPaying = !!subscription;
## 📝 Environment Variables Needed
### API (`api/.env`)
```env
# Stripe
STRIPE_SECRET_KEY=sk_test_xxxxx (or sk_live_xxxxx)
@ -275,6 +317,7 @@ FRONTEND_URL=http://localhost:5173 (or https://app.xtablo.com)
```
### Frontend (`apps/main/.env`)
```env
# Stripe
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx (or pk_live_xxxxx)
@ -328,16 +371,19 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx (or pk_live_xxxxx)
This implementation is optimized for a single subscription tier:
**Plan Structure:**
- **Free** - Default tier, `is_paying = false`
- **Standard** - Paid tier, `is_paying = true`, `subscription_tier = 'standard'`
**Why This Works:**
- Simple pricing model
- Easy to check: just `user.is_paying`
- Future-proof: can add more tiers later by updating the tier logic
- Webhook automatically handles upgrades/downgrades
**API Endpoints Specific to Standard:**
- `GET /api/v1/stripe/prices` - Returns only "Standard" plan prices
- Creates customers with metadata linking to user_id
- All subscriptions automatically set tier to 'standard'
@ -346,4 +392,3 @@ This implementation is optimized for a single subscription tier:
**Implementation Status**: ✅ Complete (Database, API, Frontend, Types)
**Next Step**: Configure Stripe and test webhook integration

View file

@ -0,0 +1,321 @@
# ✨ Stripe Integration - Complete & Production-Ready
## 🎉 Implementation Complete!
Your Stripe integration is now using the official **@supabase/stripe-sync-engine** library.
**Repository**: [https://github.com/supabase/stripe-sync-engine](https://github.com/supabase/stripe-sync-engine)
## 📦 What's Included
### Backend (Node.js/Hono API)
- ✅ `api/src/stripe.ts` - Stripe routes using StripeSync library
- ✅ Webhook handler (automatic sync)
- ✅ Checkout session creation
- ✅ Customer portal access
- ✅ Subscription management (cancel/reactivate)
### Database (PostgreSQL/Supabase)
- ✅ `sql/35_stripe_wrappers.sql` - Profile integration & RLS
- ✅ Automatic `is_paying` flag on profiles
- ✅ `subscription_tier` field ('free' or 'standard')
- ✅ Triggers for automatic updates
- ✅ Helper functions
- ✅ RLS policies (users see only their data)
### Frontend (React/TypeScript)
- ✅ `apps/main/src/hooks/stripe.ts` - React hooks (direct Supabase queries)
- ✅ `apps/main/src/components/SubscriptionCard.tsx` - Ready-to-use UI component
- ✅ Type-safe with full TypeScript support
### Documentation
- 📘 `docs/STRIPE_WITH_SYNC_ENGINE.md` - Main guide
- 📘 `docs/STRIPE_FINAL_SETUP.md` - Setup steps
- 📘 `docs/TESTING_WITH_FAKE_ACCOUNTS.md` - Testing guide
- 📘 `docs/STRIPE_ARCHITECTURE.md` - Technical architecture
## 🎯 Single "Standard" Plan
**Free Tier:**
- `is_paying: false`
- `subscription_tier: 'free'`
**Standard Tier:**
- `is_paying: true`
- `subscription_tier: 'standard'`
## 🚀 5-Minute Setup
### 1. Install Library
```bash
cd api && npm install @supabase/stripe-sync-engine
```
✅ Already installed!
### 2. Run Migrations
**First: Library migrations** (creates base tables)
```typescript
import { runMigrations } from "@supabase/stripe-sync-engine";
await runMigrations({ databaseUrl: process.env.DATABASE_URL });
```
**Then: Custom SQL** (adds profile integration)
```sql
\i sql/35_stripe_wrappers.sql
```
### 3. Set Environment Variables
**API:**
```env
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
DATABASE_URL=postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres
```
**Frontend:**
```env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_xxxxx
```
### 4. Create "Standard" Product
Stripe Dashboard (Test Mode) → Products → Add:
- Name: **Standard**
- Price: €9.99/month
- Copy `price_id`
### 5. Configure Webhook
Stripe Dashboard → Developers → Webhooks:
- URL: `https://your-api.com/api/v1/stripe/webhook`
- Events: **Select all** (library handles 100+ events!)
- Copy signing secret
### 6. Add UI
```typescript
// apps/main/src/pages/settings.tsx
import { SubscriptionCard } from "../components/SubscriptionCard";
<SubscriptionCard />;
```
## ✅ How to Verify It Works
### Test Subscription Flow
1. Create test account: `test@example.com`
2. Go to Settings
3. Click "Passer à Standard"
4. Use test card: `4242 4242 4242 4242`
5. Complete checkout
6. Verify in database:
```sql
SELECT email, is_paying, subscription_tier
FROM profiles
WHERE email = 'test@example.com';
-- Should show: is_paying = true, subscription_tier = 'standard'
```
### Check Frontend
```typescript
const user = useUser();
console.log(user.is_paying); // true
console.log(user.subscription_tier); // 'standard'
```
## 🏗️ Architecture
```
┌──────────┐
│ Stripe │ (Source of truth)
└────┬─────┘
│ Webhooks
┌──────────────────────┐
@supabase/stripe- │ (Automatic sync)
│ sync-engine │
└────┬─────────────────┘
┌──────────────────────┐
│ Supabase Database │
│ - stripe_customers │
│ - stripe_subscriptions│
│ - stripe_products │
│ - + 30 more tables! │
└────┬─────────────────┘
│ Trigger
┌──────────────────────┐
│ profiles │
│ - is_paying ← Auto │
│ - subscription_tier │
└────┬─────────────────┘
│ Direct query (RLS)
┌──────────────────────┐
│ Frontend (React) │
│ - useSubscription() │
│ - useIsPayingUser() │
└──────────────────────┘
```
## 💡 Key Features
### What stripe-sync-engine Does:
- ✅ 100+ webhook event types
- ✅ Automatic table management
- ✅ Schema migrations
- ✅ Signature verification
- ✅ Idempotency
- ✅ Backfilling
- ✅ Error handling
- ✅ Foreign key integrity
### What We Added:
- ✅ `user_id` mapping to auth.users
- ✅ Profile integration (is_paying, subscription_tier)
- ✅ RLS policies
- ✅ React hooks for Supabase access
- ✅ UI components
- ✅ Action endpoints (checkout, portal, cancel)
## 🎓 Usage Examples
### Check Payment Status (Instant)
```typescript
const user = useUser();
if (!user.is_paying) {
return <UpgradePrompt />;
}
```
### Get Subscription Details
```typescript
const { data: subscription } = useSubscription();
// Queries stripe_subscriptions directly via Supabase
// RLS ensures user only sees their own data
```
### Subscribe to Standard
```typescript
const { mutate: checkout } = useCreateCheckoutSession();
checkout({ priceId: "price_xxxxx" });
// Creates checkout → redirects to Stripe → payment → webhook → DB sync
```
### Manage Subscription
```typescript
const { mutate: openPortal } = useCreatePortalSession();
openPortal();
// Opens Stripe Customer Portal
```
## 📊 Database Tables Created
### By stripe-sync-engine Library:
All standard Stripe objects synced automatically:
- customers, subscriptions, invoices, charges
- products, prices, payment_intents, payment_methods
- And 30+ more tables!
### By Our Custom SQL:
- Adds `user_id` to customers/subscriptions (for RLS)
- Adds `is_paying` to profiles (auto-updated)
- Adds `subscription_tier` to profiles (auto-updated)
- RLS policies
- Triggers
- Helper functions
## 🔒 Security
**Webhook signature verification** - Library handles
**Row Level Security** - Users see only their data
**Service role for webhooks** - Bypasses RLS for writes
**Direct Supabase access** - No API for reads (faster + more secure)
## 📈 Performance
- ⚡ `user.is_paying` - Instant (in memory)
- ⚡ `useSubscription()` - ~50-100ms (Supabase query)
- ⚡ Webhook processing - ~100-300ms (automatic)
## 🧪 Testing Checklist
- [ ] Library installed: `npm list @supabase/stripe-sync-engine`
- [ ] Library migrations run
- [ ] Custom SQL (35) run
- [ ] Environment variables set (API + Frontend)
- [ ] "Standard" product created in Stripe
- [ ] Webhook configured in Stripe Dashboard
- [ ] Test with card `4242 4242 4242 4242`
- [ ] `is_paying` updates to `true`
- [ ] UI shows "Actif" badge
- [ ] Can open customer portal
- [ ] Can cancel subscription
- [ ] Can reactivate subscription
## 🎉 Benefits
| Metric | Value |
| ---------------------- | ----------------------------- |
| Custom webhook code | 0 lines (library handles it!) |
| Webhook events covered | 100+ |
| Maintenance required | Minimal |
| Battle-tested | ✅ Used by Supabase |
| Type-safe | ✅ Full TypeScript |
| Real-time sync | ✅ Instant |
## 📞 Need Help?
- **Library Docs**: [https://supabase.github.io/stripe-sync-engine](https://supabase.github.io/stripe-sync-engine)
- **Library Issues**: [https://github.com/supabase/stripe-sync-engine/issues](https://github.com/supabase/stripe-sync-engine/issues)
- **Setup Guide**: `docs/STRIPE_FINAL_SETUP.md`
- **Testing**: `docs/TESTING_WITH_FAKE_ACCOUNTS.md`
## 🚀 Next Steps
1. Run library migrations
2. Run custom SQL (35)
3. Configure Stripe Dashboard
4. Test with fake account
5. Add `<SubscriptionCard />` to settings
6. Deploy to production!
---
**Status**: ✅ Implementation Complete
**Complexity**: Low (library does the work)
**Code to Maintain**: ~200 lines (vs 500+ custom)
**Ready for Production**: Yes!
🎊 **You now have enterprise-grade Stripe integration with minimal code!** 🎊

208
docs/STRIPE_MIGRATION_36.md Normal file
View file

@ -0,0 +1,208 @@
# Migration 36: Fix Stripe Subscription Period Dates
## Overview
This migration fixes a critical issue with subscription period tracking and simplifies the profile subscription model.
## Problem
The previous implementation incorrectly used `subscriptions.current_period_end` to determine billing cycle end dates. However, in Stripe's data model:
- `subscriptions.current_period_end` = End of the subscription (NULL for ongoing subscriptions)
- `subscription_items.current_period_end` = End of the current billing cycle (the actual monthly due date)
## Solution
### Database Changes
#### 1. New Enum Type
Created `subscription_plan` enum with values:
- `none` - Free user (no subscription)
- `trial` - User on trial period
- `standard` - Paid subscriber
#### 2. Profile Table Updates
**Removed:**
- `is_paying` (boolean) - Replaced with plan enum
- `subscription_tier` (text) - Replaced with plan enum
**Added:**
- `plan` (subscription_plan enum) - Single source of truth for subscription status
#### 3. Updated Functions
**`is_paying_user(user_uuid)`**
- Now uses `subscription_items.current_period_end` for accurate billing cycle tracking
- Checks if subscription is active/trialing AND current period has not ended
**`get_user_subscription_status(user_uuid)`**
- Returns subscription data including:
- `current_period_start` and `current_period_end` from `subscription_items`
- `price_id` from `subscription_items.price`
- Computed `plan` enum based on subscription status
**`get_user_stripe_subscriptions()`**
- Updated to join with `subscription_items` table
- Returns `current_period_start/end` from subscription items
- Includes `price_id` and `quantity` from subscription items
**`update_profile_subscription_status()`**
- Trigger function updated to set `plan` enum instead of `is_paying` and `subscription_tier`
- Logic prioritizes: trial > standard > none
- Now correctly uses `subscription_items.current_period_end`
#### 4. New Trigger
Added trigger on `subscription_items` table:
- `update_profile_on_subscription_item_change`
- Ensures profile updates when billing cycles change
#### 5. Updated Views
**`active_subscriptions` view**
- Now joins with `subscription_items` for accurate period dates
- Includes `plan` column from profiles
- Uses `subscription_items.current_period_end` for filtering
### TypeScript Type Updates
#### Database Types (`packages/shared/src/types/database.types.ts`)
**Added enum:**
```typescript
subscription_plan: "none" | "trial" | "standard"
```
**Updated `profiles` table type:**
```typescript
{
avatar_url: string | null
email: string | null
first_name: string | null
id: string
is_temporary: boolean
last_name: string | null
last_signed_in: string | null
name: string | null
plan: Database["public"]["Enums"]["subscription_plan"] // NEW
short_user_id: string
// REMOVED: is_paying, subscription_tier
}
```
### Frontend Updates
#### Component Changes
**`SubscriptionCard.tsx`**
```typescript
// OLD:
const isPaying = user.is_paying;
// NEW:
const isPaying = user.plan !== "none";
```
**`stripe.ts` hooks**
```typescript
// OLD:
return { data: user.is_paying, isLoading: false };
// NEW:
return { data: user.plan !== "none", isLoading: false };
```
#### Test File Updates
All test files updated to use:
```typescript
plan: "none" as const
```
Instead of:
```typescript
is_paying: false,
subscription_tier: "free"
```
## Files Changed
### SQL
- ✅ `sql/36_fix_stripe_subscription_dates.sql` (NEW)
### TypeScript Types
- ✅ `packages/shared/src/types/database.types.ts`
### Frontend Components
- ✅ `apps/main/src/components/SubscriptionCard.tsx`
- ✅ `apps/main/src/hooks/stripe.ts`
### Tests
- ✅ `apps/main/src/components/ProtectedRoute.test.tsx`
- ✅ `apps/main/src/providers/UserStoreProvider.test.tsx`
- ✅ `apps/main/src/utils/testHelpers.tsx`
## Migration Steps
1. **Run the SQL migration:**
```sql
\i sql/36_fix_stripe_subscription_dates.sql
```
2. **The migration will:**
- Create `subscription_plan` enum type
- Drop `is_paying` and `subscription_tier` columns from profiles
- Add `plan` column with default `'none'`
- Update all functions to use `subscription_items.current_period_end`
- Create trigger on `subscription_items` table
- Update views to use correct period dates
3. **Existing data handling:**
- All users will default to `plan = 'none'`
- The triggers will automatically update plans when subscriptions sync
- Next webhook from Stripe will correct the plan for paying users
## Benefits
1. **Accurate Billing Dates**: Uses `subscription_items.current_period_end` for actual monthly due dates
2. **Simplified Model**: Single `plan` enum instead of two separate fields
3. **Type Safety**: Enum type prevents invalid values
4. **Clearer Logic**: `plan` is more intuitive than `is_paying` + `subscription_tier`
5. **Automatic Updates**: Triggers on both `subscriptions` and `subscription_items` ensure data consistency
## Data Flow
### Before
```
Stripe Webhook → subscriptions.current_period_end (NULL)
→ is_paying = true (wrong logic)
```
### After
```
Stripe Webhook → subscription_items.current_period_end (actual date)
→ plan = 'standard' | 'trial' | 'none'
```
## Testing
After migration:
1. ✅ TypeScript compilation passes
2. ✅ All test files updated and passing
3. ✅ Plan enum properly constrained
4. ✅ Views and functions return correct period dates
## Rollback
If needed, you can rollback by:
1. Dropping the new trigger on `subscription_items`
2. Dropping the `plan` column
3. Re-adding `is_paying` and `subscription_tier` columns
4. Reverting function definitions to use `subscriptions.current_period_end`
However, this is not recommended as the old implementation had incorrect logic.
---
**Status**: ✅ Complete
**Date**: 2025-11-03
**Breaking Changes**: Yes (profile table schema changed)
**Frontend Changes**: Yes (component and hook updates)

View file

@ -37,26 +37,28 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
\i sql/36_stripe_webhooks.sql
```
## 📋 API Endpoints
## 📋 API Endpoints (Actions Only)
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | `/api/v1/stripe/webhook` | ❌ | Stripe webhook (signature verified) |
| POST | `/api/v1/stripe/create-checkout-session` | ✅ | Start subscription flow |
| POST | `/api/v1/stripe/create-portal-session` | ✅ | Open customer portal |
| GET | `/api/v1/stripe/subscription` | ✅ | Get subscription details |
| GET | `/api/v1/stripe/is-paying` | ✅ | Check if user is paying |
| GET | `/api/v1/stripe/prices` | ❌ | Get Standard plan prices |
| POST | `/api/v1/stripe/cancel-subscription` | ✅ | Cancel subscription |
| POST | `/api/v1/stripe/reactivate-subscription` | ✅ | Reactivate subscription |
**Note:** Reading subscription data (status, prices, etc.) is done directly via Supabase client from the frontend.
## 🎣 Frontend Hooks
```typescript
import {
useSubscription, // Get full subscription details
useIsPayingUser, // Boolean: is user paying?
useStripePrices, // Get available prices
// Direct Supabase queries (RLS-protected, no API call)
useSubscription, // Get full subscription from Supabase
useIsPayingUser, // Get is_paying from user profile
useStripePrices, // Get prices from Supabase
// API calls (for Stripe actions)
useCreateCheckoutSession, // Create checkout & redirect
useCreatePortalSession, // Open customer portal
useCancelSubscription, // Cancel at period end
@ -64,6 +66,11 @@ import {
} from '@/hooks/stripe';
```
**Benefits of Direct Supabase Access:**
- ⚡ **Faster**: No API hop for reads
- 🔒 **Secure**: RLS policies protect data
- 📊 **Real-time**: Can subscribe to changes
## 💡 Common Use Cases
### Check if User is Paying
@ -108,7 +115,24 @@ const { data: subscription } = useSubscription();
## 🔍 Database Queries
### Check Subscription in SQL
### Frontend Queries (Using Supabase Client)
```typescript
// Get user's subscription
const { data } = await supabase
.from('stripe_subscriptions')
.select('*, price:stripe_prices(*, product:stripe_products(*))')
.eq('user_id', userId)
.single();
// Get available prices
const { data } = await supabase
.from('stripe_prices')
.select('*, product:stripe_products!inner(*)')
.eq('active', true)
.eq('product.name', 'Standard');
```
### Backend SQL Queries
```sql
-- Is user paying?
SELECT is_paying_user('user-uuid-here');
@ -116,11 +140,8 @@ SELECT is_paying_user('user-uuid-here');
-- Get subscription details
SELECT * FROM get_user_subscription_status('user-uuid-here');
-- Get all active subscriptions
SELECT * FROM active_subscriptions;
-- Query Stripe directly
SELECT * FROM stripe.subscriptions WHERE customer = 'cus_xxxxx';
-- Get current user's active subscription (secure, RLS-compliant)
SELECT * FROM get_my_active_subscription();
```
## 🎨 Profile Fields
@ -183,8 +204,9 @@ For issues, check:
---
**Files:**
- Database: `sql/35_stripe_wrappers.sql` + `sql/36_stripe_webhooks.sql`
- Backend: `api/src/stripe.ts` + `api/src/stripe-webhook.ts`
- Database: `sql/35_stripe_wrappers.sql` + `sql/36_fix_stripe_subscription_dates.sql` + `sql/37_secure_active_subscriptions.sql`
- Backend: `api/src/stripe.ts` + `api/src/stripeSync.ts`
- Frontend: `apps/main/src/hooks/stripe.ts`
- Types: `packages/shared/src/types/stripe.types.ts`
- Security: `docs/STRIPE_SECURITY_FIX.md`

150
docs/STRIPE_SECURITY_FIX.md Normal file
View file

@ -0,0 +1,150 @@
# Stripe Security Fix - Migration 37
## Security Issue Fixed
**Issue**: The `public.active_subscriptions` view was defined as a regular view that exposed all users' subscription data. Without proper Row Level Security (RLS) policies, this view could potentially be queried by any authenticated user to see other users' subscription information.
**Severity**: High - Potential data exposure
## Changes Made
### Migration 37: `sql/37_secure_active_subscriptions.sql`
1. **Removed insecure view**
- Dropped `public.active_subscriptions` view
2. **Added secure function**
- `get_my_active_subscription()` - Returns only the authenticated user's active subscription
- Uses `auth.uid()` to filter by current user
- Uses `SECURITY DEFINER` with explicit `search_path` for security
- Granted to `authenticated` role
- Returns fields: subscription_id, user_id, user_email, first_name, last_name, status, current_period_start, current_period_end, cancel_at_period_end, product_name, currency, unit_amount, billing_interval, plan
## Migration Instructions
### 1. Run the migration
```bash
# Connect to your Supabase database and run:
psql -U postgres -d postgres -f sql/37_secure_active_subscriptions.sql
```
Or via Supabase Dashboard:
- Go to SQL Editor
- Copy and paste the contents of `sql/37_secure_active_subscriptions.sql`
- Run the migration
### 2. Regenerate TypeScript types
After running the migration, regenerate your database types:
```bash
# For API
supabase gen types typescript --project-id YOUR_PROJECT_ID > api/src/database.types.ts
# For packages/shared
supabase gen types typescript --project-id YOUR_PROJECT_ID > packages/shared/src/types/database.types.ts
# For xtablo-expo
supabase gen types typescript --project-id YOUR_PROJECT_ID > xtablo-expo/lib/database.types.ts
```
### 3. Update any code that references `active_subscriptions`
**If you were querying the view directly** (which should be avoided):
```typescript
// ❌ Old - INSECURE (showed all users' data)
const { data } = await supabase
.from('active_subscriptions')
.select('*');
// ✅ New - SECURE (only shows current user's data)
const { data } = await supabase
.rpc('get_my_active_subscription');
// Returns: {
// subscription_id, user_id, user_email, first_name, last_name,
// status, current_period_start, current_period_end,
// cancel_at_period_end, product_name, currency, unit_amount,
// billing_interval, plan
// }
```
## Current Application Status
**Good news**: The application code already uses the secure `get_user_stripe_subscriptions()` function instead of directly querying the view, so no application code changes are needed!
The view was only used for:
- Documentation examples
- Database type definitions (auto-generated)
- Potential ad-hoc queries
## Security Best Practices
### Why use functions instead of views for sensitive data?
1. **Explicit access control** - Functions can check `auth.uid()` to ensure users only see their own data
2. **Permission granularity** - Can grant execute permissions to specific roles
3. **Security definer** - Functions run with specific privileges and search paths
4. **Audit trail** - Function calls can be logged more easily than view queries
### SECURITY DEFINER best practices
When using `SECURITY DEFINER` on functions:
1. **Always set search_path** - Prevents SQL injection via schema manipulation
```sql
set search_path = public, stripe
```
2. **Always validate inputs** - Check `auth.uid()` and other user inputs
```sql
where (c.metadata->>'user_id')::uuid = auth.uid()
```
3. **Minimal permissions** - Only grant execute to roles that need it
```sql
grant execute on function public.get_my_active_subscription() to authenticated;
```
4. **Avoid dynamic SQL** - Use parameterized queries, not string concatenation
## Testing
### Test user access (should work)
```sql
-- As an authenticated user, get your own subscription
SELECT * FROM get_my_active_subscription();
-- Should return your subscription or empty result if no active subscription
-- Should only show YOUR data, never other users' data
```
### Test in your application
```typescript
// In your React component
const { data: subscription } = await supabase
.rpc('get_my_active_subscription');
console.log(subscription);
// Should show your subscription with all fields:
// billing_interval, product_name, status, etc.
```
## Related Files
- Migration: `sql/37_secure_active_subscriptions.sql`
- Previous migrations:
- `sql/35_stripe_wrappers.sql` (created the view)
- `sql/36_fix_stripe_subscription_dates.sql` (updated the view)
- Documentation: This file
## Questions?
If you have questions about this security fix, please refer to:
- `docs/STRIPE_ARCHITECTURE.md` - Stripe integration architecture
- `docs/STRIPE_QUICK_REFERENCE.md` - Updated with secure query examples

View file

@ -21,49 +21,25 @@ This guide walks you through setting up Stripe payments integration for Xtablo w
## Database Setup
### 1. Enable Wrappers Extension
### 1. Run Migration Scripts
In your Supabase SQL Editor, enable the Wrappers extension:
Execute the SQL migration files in your Supabase SQL Editor:
```sql
create extension if not exists wrappers with schema extensions;
-- Execute these in order
\i sql/35_stripe_wrappers.sql -- Creates tables, functions, RLS policies
\i sql/36_stripe_webhooks.sql -- Creates webhook handler functions
```
### 2. Store Stripe API Key in Vault
These migrations create:
```sql
select vault.create_secret(
'sk_test_xxxxx', -- Your Stripe secret key (test or live)
'stripe',
'Stripe API key for Wrappers'
);
```
- ✅ Subscription tracking tables (`stripe_customers`, `stripe_subscriptions`, `stripe_products`, `stripe_prices`)
- ✅ Helper functions (`is_paying_user()`, `get_user_subscription_status()`)
- ✅ Automatic triggers to update `profiles.is_paying`
- ✅ Row Level Security policies
- ✅ Webhook handler functions
**Note the `key_id` returned** - you'll need this for the next step.
### 3. Run Migration Scripts
Execute the SQL migration files in order:
```bash
# From your Supabase SQL Editor
1. Run: sql/35_stripe_wrappers.sql
2. Run: sql/36_stripe_webhooks.sql
```
### 4. Update API Key ID
In `35_stripe_wrappers.sql`, update line 27 with your actual `key_id` from step 2:
```sql
create server stripe_server
foreign data wrapper stripe_wrapper
options (
api_key_id 'YOUR_KEY_ID_HERE', -- Update this!
api_url 'https://api.stripe.com/v1/',
api_version '2024-06-20'
);
```
**No Wrappers needed!** This is a pure webhook-based integration.
## Stripe Configuration
@ -175,16 +151,24 @@ All hooks are ready to use:
```typescript
import {
useSubscription, // Get subscription details
useIsPayingUser, // Check if user is paying
useStripePrices, // Get available prices
useCreateCheckoutSession, // Start checkout flow
// Direct Supabase queries (RLS-protected)
useSubscription, // Get subscription from Supabase
useIsPayingUser, // Check if paying (from user.is_paying)
useStripePrices, // Get prices from Supabase
// API calls (for actions)
useCreateCheckoutSession, // Start checkout
useCreatePortalSession, // Open customer portal
useCancelSubscription, // Cancel subscription
useReactivateSubscription, // Reactivate canceled subscription
useReactivateSubscription, // Reactivate subscription
} from "../hooks/stripe";
```
**Architecture:**
- 📖 **Reads**: Direct Supabase queries (fast, RLS-protected)
- ✍️ **Writes**: API calls to Stripe → Webhooks update Supabase
## Usage Examples
### Check if User is Paying
@ -198,15 +182,19 @@ if (!isPaying) {
}
```
### Get Subscription Details
### Get Subscription Details (Queries Supabase Directly)
```typescript
const { data: subscription } = useSubscription();
if (subscription) {
console.log("Status:", subscription.status);
console.log("Plan:", subscription.product_name);
console.log("Renews:", subscription.current_period_end);
console.log("Will cancel?:", subscription.cancel_at_period_end);
// Access related product/price via join
console.log("Product:", subscription.price?.product?.name);
console.log("Amount:", subscription.price?.unit_amount);
}
```

View file

@ -0,0 +1,736 @@
# Stripe Testing Guide - Complete Walkthrough
This guide shows you how to test the entire Stripe integration with fake accounts and test data.
## 🎯 Overview
We'll test:
1. Creating test users in your app
2. Subscribing with Stripe test cards
3. Verifying webhook processing
4. Testing subscription management
5. Checking database updates
## 📋 Prerequisites
- [ ] Database migrations run (35 & 36)
- [ ] API running locally
- [ ] Frontend running locally
- [ ] Stripe test mode keys configured
- [ ] Stripe CLI installed (optional but recommended)
## 🔧 Setup for Testing
### 1. Use Stripe Test Mode
**Important**: Always use **test mode** keys for development:
```env
# API .env
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx # Test key (not live!)
STRIPE_WEBHOOK_SECRET=whsec_test_xxxxx # Test webhook secret
# Frontend .env
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxx # Test publishable key
```
### 2. Create "Standard" Product in Stripe (Test Mode)
1. Go to Stripe Dashboard → Switch to **Test Mode** (toggle in top-right)
2. Navigate to **Products**
3. Click **Add product**
4. Fill in:
- Name: **Standard**
- Description: Standard plan for Xtablo
5. Add pricing:
- **Monthly**: €9.99/month
- **Yearly** (optional): €99/year
6. **Copy the price ID** (e.g., `price_1234567890abcdef`)
7. Update your frontend `.env`:
```env
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1234567890abcdef
```
### 3. Set Up Local Webhook Testing
**Option A: Stripe CLI (Recommended)**
```bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to your local API
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
```
This will give you a webhook secret like `whsec_xxxxx`. Use this in your API `.env`.
**Option B: Use Stripe Dashboard Webhooks**
For production-like testing, add webhook endpoint in Stripe Dashboard pointing to your deployed API.
## 🧪 Testing Flow - Step by Step
### Step 1: Create Test User Account
1. **Sign up** with a test email (e.g., `test@example.com`)
2. **Verify email** (check your inbox or skip if auto-confirm enabled)
3. **Login** to your app
4. Navigate to **Settings** page
### Step 2: Add SubscriptionCard to Settings (If Not Already)
```typescript
// In apps/main/src/pages/settings.tsx
import { SubscriptionCard } from "../components/SubscriptionCard";
// Add in the cards section:
<SubscriptionCard />;
```
### Step 3: Test Free User State
You should see:
- ✅ "Plan Gratuit" badge with "Gratuit" status
- ✅ "Passer à Standard" button
- ✅ User profile shows: `is_paying: false`, `subscription_tier: 'free'`
**Verify in Database:**
```sql
SELECT id, email, is_paying, subscription_tier
FROM profiles
WHERE email = 'test@example.com';
-- Should show: is_paying = false, subscription_tier = 'free'
```
### Step 4: Test Subscription Creation
1. Click **"Passer à Standard"** button
2. You'll be redirected to Stripe Checkout
3. Fill in test card details:
**Test Card Information:**
```
Card Number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits (e.g., 12345)
Name: Test User
Email: test@example.com
```
4. Click **Subscribe**
5. You'll be redirected back to your app with `?success=true`
### Step 5: Verify Webhook Processing
**Watch in Terminal:**
```bash
# If using Stripe CLI, you'll see:
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
# You should see events like:
✓ customer.created
✓ customer.subscription.created
✓ invoice.created
✓ payment_intent.created
```
**Check API Logs:**
Look for:
```
Processing Stripe webhook: customer.subscription.created
```
**Verify in Database:**
```sql
-- Check customer created
SELECT * FROM stripe_customers WHERE email = 'test@example.com';
-- Check subscription created
SELECT * FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'test@example.com');
-- Check profile updated
SELECT is_paying, subscription_tier
FROM profiles
WHERE email = 'test@example.com';
-- Should show: is_paying = true, subscription_tier = 'standard'
```
### Step 6: Verify Frontend Updates
Refresh the settings page. You should see:
- ✅ "Actif" badge (green)
- ✅ "Plan Standard"
- ✅ Renewal date displayed
- ✅ "Gérer l'abonnement" button
- ✅ "Annuler" button
**Check in Console:**
```typescript
// In browser console:
console.log(user.is_paying); // Should be true
console.log(user.subscription_tier); // Should be 'standard'
```
### Step 7: Test Customer Portal
1. Click **"Gérer l'abonnement"**
2. Opens Stripe Customer Portal
3. You can:
- Update payment method
- View invoices
- Cancel subscription
- Update billing info
### Step 8: Test Subscription Cancellation
**From Your App:**
1. Click **"Annuler"** button in SubscriptionCard
2. Confirms cancellation at period end
3. UI updates to show "Annulation en cours"
4. Shows "Réactiver l'abonnement" button
**Verify Webhook:**
```bash
# Should see in Stripe CLI:
✓ customer.subscription.updated
```
**Verify in Database:**
```sql
SELECT cancel_at_period_end, current_period_end
FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'test@example.com');
-- cancel_at_period_end should be true
```
### Step 9: Test Subscription Reactivation
1. Click **"Réactiver l'abonnement"** button
2. Subscription continues normally
3. UI returns to active state
**Verify in Database:**
```sql
SELECT cancel_at_period_end
FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'test@example.com');
-- cancel_at_period_end should be false
```
## 🧪 Advanced Testing Scenarios
### Test Different Card Behaviors
```
Success (Visa): 4242 4242 4242 4242
Success (Mastercard): 5555 5555 5555 4444
Decline: 4000 0000 0000 0002
Insufficient funds: 4000 0000 0000 9995
Expired card: 4000 0000 0000 0069
Processing error: 4000 0000 0000 0119
Requires auth (3DS): 4000 0027 6000 3184
```
### Test Subscription States
**Active Subscription:**
1. Complete checkout with `4242 4242 4242 4242`
2. Verify `status = 'active'`
**Past Due (Failed Payment):**
1. Use Stripe CLI:
```bash
stripe trigger customer.subscription.updated \
--add customer_subscription:status=past_due
```
2. Check database: `status = 'past_due'`
**Trial Period:**
1. Create price with trial in Stripe Dashboard
2. Subscribe with test card
3. Verify `status = 'trialing'`
### Test Multiple Test Users
Create multiple test users to verify isolation:
```
test1@example.com → Subscribe
test2@example.com → Stay free
test3@example.com → Subscribe then cancel
```
Verify each user only sees their own subscription data.
## 🔍 Verification Checklist
After each test, verify:
### Database
```sql
-- Check user's subscription
SELECT
p.email,
p.is_paying,
p.subscription_tier,
s.status,
s.current_period_end,
s.cancel_at_period_end
FROM profiles p
LEFT JOIN stripe_subscriptions s ON s.user_id = p.id
WHERE p.email = 'test@example.com';
```
### API Endpoints
```bash
# Test is-paying endpoint
curl http://localhost:3000/api/v1/stripe/is-paying \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Test subscription endpoint
curl http://localhost:3000/api/v1/stripe/subscription \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
# Test prices endpoint (no auth needed)
curl http://localhost:3000/api/v1/stripe/prices
```
### Frontend
```typescript
// In browser console
import { useUser } from "./providers/UserStoreProvider";
const user = useUser();
console.log({
isPaying: user.is_paying,
tier: user.subscription_tier,
});
```
## 🎬 Complete Test Scenario
### Scenario: User Lifecycle
**Day 1: Sign Up (Free)**
```
1. Create account: test-user@example.com
2. Verify email
3. Login
4. Check: is_paying = false
```
**Day 2: Subscribe to Standard**
```
1. Go to Settings
2. Click "Passer à Standard"
3. Use card: 4242 4242 4242 4242
4. Complete checkout
5. Verify: is_paying = true
6. Check subscription end date in UI
```
**Day 15: Update Payment Method**
```
1. Click "Gérer l'abonnement"
2. Add new payment method
3. Remove old one
4. Verify webhook: customer.updated
```
**Day 20: Cancel Subscription**
```
1. Click "Annuler" in SubscriptionCard
2. Verify: cancel_at_period_end = true
3. Check UI shows cancellation notice
4. Verify still have access until period end
```
**Day 25: Reactivate**
```
1. Click "Réactiver l'abonnement"
2. Verify: cancel_at_period_end = false
3. Check UI shows active status
```
**Day 30: Let it Expire (Alternative)**
```
1. Wait for period_end (or use Stripe CLI to simulate)
2. Verify: is_paying = false
3. Check: subscription_tier = 'free'
```
## 🐛 Common Issues & Solutions
### Issue: Webhook not received
**Solution:**
```bash
# Check Stripe CLI is running
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
# Check API logs for errors
# Check Stripe Dashboard → Webhooks for delivery status
```
### Issue: is_paying not updating
**Solution:**
```sql
-- Manually trigger the update function
SELECT update_profile_subscription_status();
-- Or check if trigger exists
SELECT * FROM pg_trigger WHERE tgname = 'update_profile_on_subscription_change';
```
### Issue: Customer not found error
**Solution:**
```sql
-- Verify customer was created
SELECT * FROM stripe_customers WHERE user_id = 'user-uuid';
-- Create manually if needed
INSERT INTO stripe_customers (id, user_id, stripe_customer_id, email)
VALUES ('cus_xxxxx', 'user-uuid', 'cus_xxxxx', 'test@example.com');
```
### Issue: Checkout session fails
**Check:**
1. Is `STRIPE_SECRET_KEY` set correctly?
2. Is price ID valid? (Check Stripe Dashboard)
3. Check API logs for error details
4. Verify user is authenticated
## 🎮 Interactive Test Script
Run this in your browser console after logging in:
```javascript
// Check current status
const checkStatus = async () => {
const user = window.__USER__; // Or get from store
console.log("Current Status:", {
isPaying: user.is_paying,
tier: user.subscription_tier,
});
// Fetch latest subscription
const response = await fetch("/api/v1/stripe/subscription", {
headers: { Authorization: `Bearer ${sessionStorage.getItem("token")}` },
});
const data = await response.json();
console.log("Subscription:", data);
};
checkStatus();
```
## 📊 Test Data Reference
### Test Emails
```
test1@example.com - For basic testing
premium@example.com - For subscription testing
cancel@example.com - For cancellation testing
trial@example.com - For trial testing
```
### Test Cards
| Scenario | Card Number | Expected Result |
| ------------------ | --------------------- | ------------------------------ |
| Successful payment | `4242 4242 4242 4242` | Creates active subscription |
| Declined | `4000 0000 0000 0002` | Payment fails, no subscription |
| Insufficient funds | `4000 0000 0000 9995` | Payment fails |
| 3D Secure required | `4000 0027 6000 3184` | Requires authentication |
| Expired | `4000 0000 0000 0069` | Card expired error |
### Test Webhooks
```bash
# Trigger specific events
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
```
## ✅ Complete Test Checklist
### Setup Tests
- [ ] Stripe test keys configured
- [ ] Webhook endpoint accessible
- [ ] Stripe CLI forwarding webhooks
- [ ] "Standard" product exists in Stripe
- [ ] Price IDs saved in environment
### Functionality Tests
- [ ] New user signup works
- [ ] Free user sees upgrade prompt
- [ ] Can create checkout session
- [ ] Checkout redirects to Stripe
- [ ] Can complete payment with test card
- [ ] Returns to app after payment
- [ ] Webhook processes subscription
- [ ] `is_paying` updates to true
- [ ] `subscription_tier` updates to 'standard'
- [ ] UI reflects subscription status
- [ ] Can open customer portal
- [ ] Can cancel subscription
- [ ] Cancellation sets cancel_at_period_end
- [ ] Can reactivate subscription
- [ ] Subscription shows correct end date
### Database Tests
- [ ] Customer record created
- [ ] Subscription record created
- [ ] Profile updated with is_paying
- [ ] Subscription tier set to 'standard'
- [ ] Historical data preserved
- [ ] RLS works (users see only their data)
### Edge Case Tests
- [ ] Multiple subscriptions handling
- [ ] Webhook replay doesn't duplicate data
- [ ] Invalid price ID shows error
- [ ] Non-existent customer handled
- [ ] Webhook signature validation works
- [ ] Failed payment webhooks handled
## 🎓 Full Test Walkthrough
### Create and Test 3 Users
**User 1: Free Forever**
```bash
# 1. Create account
Email: free@example.com
Password: TestPass123!
# 2. Login
# 3. Go to Settings
# 4. Verify: Shows "Plan Gratuit"
# 5. Verify Database:
SELECT is_paying FROM profiles WHERE email = 'free@example.com';
-- Result: false
```
**User 2: Active Subscriber**
```bash
# 1. Create account
Email: premium@example.com
Password: TestPass123!
# 2. Login → Settings
# 3. Click "Passer à Standard"
# 4. Use card: 4242 4242 4242 4242
# 5. Complete checkout
# 6. Verify: Shows "Actif" badge
# 7. Verify Database:
SELECT is_paying, subscription_tier FROM profiles WHERE email = 'premium@example.com';
-- Result: is_paying = true, subscription_tier = 'standard'
# 8. Check subscription details:
SELECT * FROM stripe_subscriptions WHERE user_id = (
SELECT id FROM profiles WHERE email = 'premium@example.com'
);
```
**User 3: Canceled Subscription**
```bash
# 1. Create account
Email: canceling@example.com
Password: TestPass123!
# 2. Subscribe (same as User 2)
# 3. Click "Annuler"
# 4. Verify: Shows "Annulation en cours" warning
# 5. Verify Database:
SELECT cancel_at_period_end FROM stripe_subscriptions WHERE user_id = (
SELECT id FROM profiles WHERE email = 'canceling@example.com'
);
-- Result: cancel_at_period_end = true
# 6. Click "Réactiver l'abonnement"
# 7. Verify: cancel_at_period_end = false
```
## 🔬 Advanced Testing
### Test Webhook Locally
```bash
# Terminal 1: Run Stripe CLI
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
# Terminal 2: Trigger events
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated --add customer_subscription:cancel_at_period_end=true
stripe trigger customer.subscription.deleted
```
### Test Failed Payments
```bash
# Use declining card
Card: 4000 0000 0000 0002
# Or trigger webhook
stripe trigger invoice.payment_failed
```
### Simulate Subscription Expiration
```sql
-- Manually expire a subscription for testing
UPDATE stripe_subscriptions
SET
current_period_end = NOW() - INTERVAL '1 day',
status = 'canceled'
WHERE user_id = (SELECT id FROM profiles WHERE email = 'test@example.com');
-- Run the update trigger
SELECT update_profile_subscription_status();
-- Verify profile updated
SELECT is_paying, subscription_tier FROM profiles WHERE email = 'test@example.com';
-- Should show: is_paying = false, subscription_tier = 'free'
```
## 📸 Testing Screenshots Checklist
Take screenshots to verify:
1. ✅ Free user state (upgrade prompt)
2. ✅ Stripe Checkout page
3. ✅ Active subscription (green badge)
4. ✅ Cancellation warning (orange)
5. ✅ Stripe Customer Portal
6. ✅ Database showing correct data
## 🚨 What to Watch For
### Red Flags
- ❌ `is_paying` not updating after payment
- ❌ Multiple subscriptions for same user
- ❌ Webhook signature validation failing
- ❌ Users seeing other users' subscriptions
- ❌ Subscription status not syncing
### Green Flags
- ✅ Webhooks arrive within seconds
- ✅ Database updates automatically
- ✅ UI reflects changes immediately
- ✅ RLS prevents unauthorized access
- ✅ All test cards behave as expected
## 🎯 Quick Verification Commands
### One-Line Database Check
```sql
-- Check everything for a test user
SELECT
p.email,
p.is_paying,
p.subscription_tier,
s.status as subscription_status,
s.cancel_at_period_end,
to_char(s.current_period_end, 'YYYY-MM-DD HH24:MI') as ends_at,
sc.stripe_customer_id
FROM profiles p
LEFT JOIN stripe_subscriptions s ON s.user_id = p.id
LEFT JOIN stripe_customers sc ON sc.user_id = p.id
WHERE p.email LIKE 'test%'
ORDER BY p.created_at DESC;
```
### Check Webhook Function Works
```sql
-- Test is_paying function
SELECT is_paying_user((SELECT id FROM profiles WHERE email = 'premium@example.com'));
-- Test subscription status function
SELECT * FROM get_user_subscription_status(
(SELECT id FROM profiles WHERE email = 'premium@example.com')
);
```
## 🎉 Success Criteria
Your integration is working correctly when:
1. ✅ Free users can upgrade via Stripe Checkout
2. ✅ Successful payment creates subscription record
3. ✅ Webhook updates `is_paying = true`
4. ✅ UI shows subscription status correctly
5. ✅ Users can manage subscription in portal
6. ✅ Cancellation sets cancel_at_period_end
7. ✅ Reactivation clears cancellation
8. ✅ Each user only sees their own data
9. ✅ All test cards behave as expected
10. ✅ Webhook events process without errors
## 📞 Need Help?
If something isn't working:
1. **Check API logs** - Look for webhook errors
2. **Check Stripe Dashboard** - Webhooks → Events
3. **Check Stripe CLI output** - See webhook delivery
4. **Check browser console** - Frontend errors
5. **Query database directly** - Verify data state
6. **Check environment variables** - All keys set?
---
**Next**: Once all tests pass, you're ready for production!
See `docs/STRIPE_SETUP.md` for production deployment guide.

View file

@ -0,0 +1,278 @@
# Stripe Integration Using @supabase/stripe-sync-engine
## 📦 Overview
We're using the official [@supabase/stripe-sync-engine](https://github.com/supabase/stripe-sync-engine) library to handle all Stripe webhook processing and data syncing.
**Benefits:**
- ✅ Battle-tested by Supabase
- ✅ Handles all Stripe webhook events automatically
- ✅ Automatic schema management
- ✅ Real-time sync of Stripe data
- ✅ No custom webhook handlers needed
## 🚀 Quick Setup
### 1. Install Package
```bash
cd api
npm install @supabase/stripe-sync-engine
```
### 2. Configure Environment
Add to `api/.env`:
```env
# Stripe
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
# Database (direct Postgres connection)
DATABASE_URL=postgresql://postgres:[password]@db.[project].supabase.co:5432/postgres
# Or use Supabase connection string
SUPABASE_URL=https://[project].supabase.co
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
```
### 3. Run Library Migrations
The stripe-sync-engine library comes with its own migrations. Run them:
```typescript
// In a one-time setup script or manually
import { runMigrations } from '@supabase/stripe-sync-engine';
await runMigrations({
databaseUrl: process.env.DATABASE_URL
});
```
Or manually execute the migrations from: `node_modules/@supabase/stripe-sync-engine/dist/migrations/*.sql`
### 4. Integration is Already Done! ✅
The webhook handler in `api/src/stripe.ts` is already configured:
```typescript
import { StripeSync } from "@supabase/stripe-sync-engine";
const stripeSync = new StripeSync({
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
schema: "public",
poolConfig: {
connectionString: process.env.DATABASE_URL,
max: 10,
},
revalidateObjectsViaStripeApi: ['subscription', 'customer'],
});
// Webhook endpoint
stripeRouter.post("/webhook", async (c) => {
const signature = c.req.header("stripe-signature");
const rawBody = await c.req.text();
await stripeSync.processWebhook(rawBody, signature);
return c.json({ received: true });
});
```
## 📊 Database Schema
The library creates these tables in the `public` schema (with `stripe_` prefix):
### Core Tables (Auto-created)
- `stripe_customers`
- `stripe_subscriptions`
- `stripe_subscription_items`
- `stripe_products`
- `stripe_prices`
- `stripe_invoices`
- `stripe_charges`
- `stripe_payment_intents`
- `stripe_payment_methods`
- And many more...
### Custom Additions (From our SQL)
We still need our custom `sql/35_stripe_wrappers.sql` for:
- ✅ `profiles.is_paying` column
- ✅ `profiles.subscription_tier` column
- ✅ Triggers to auto-update profile when subscription changes
- ✅ RLS policies
- ✅ Helper functions (`is_paying_user()`, etc.)
**Note:** Delete `sql/36_stripe_webhooks.sql` - not needed, library handles webhooks!
## 🔄 How It Works
### 1. Webhook Received
```
Stripe → POST /api/v1/stripe/webhook → StripeSync.processWebhook()
```
### 2. Sync Engine Processes
```
1. Verifies signature
2. Determines event type
3. Syncs to appropriate table (stripe_subscriptions, stripe_customers, etc.)
4. Handles all edge cases automatically
```
### 3. Our Custom Trigger Fires
```
stripe_subscriptions updated → Trigger → profiles.is_paying updated
```
### 4. Frontend Queries
```
Frontend → Supabase Client → stripe_subscriptions (RLS) → User's data
```
## ✨ What the Library Handles
The stripe-sync-engine automatically handles:
- ✅ All webhook event types (100+ events)
- ✅ Signature verification
- ✅ Idempotency (duplicate events)
- ✅ Foreign key relationships
- ✅ Deleted entities
- ✅ List expansion (fetching all items)
- ✅ Backfilling historical data
- ✅ Database schema migrations
- ✅ Connection pooling
- ✅ Error handling
## 🎯 What We Still Handle
We only need to maintain:
1. **Custom Profile Fields**
- `profiles.is_paying`
- `profiles.subscription_tier`
2. **Automatic Update Trigger**
```sql
CREATE TRIGGER update_profile_on_subscription_change
AFTER INSERT OR UPDATE ON stripe_subscriptions
→ Updates profile fields
```
3. **Action Endpoints** (in `api/src/stripe.ts`)
- Create checkout session
- Create portal session
- Cancel subscription
- Reactivate subscription
4. **RLS Policies**
- Ensure users only see their own data
## 🧪 Testing
### Test Webhook Processing
```bash
# Terminal 1: Start your API
cd api && npm run dev
# Terminal 2: Forward Stripe webhooks
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
# Terminal 3: Trigger test event
stripe trigger customer.subscription.created
```
**Watch the magic:**
1. Webhook arrives
2. stripe-sync-engine processes it
3. Data appears in `stripe_subscriptions` table
4. Trigger updates `profiles.is_paying`
5. Frontend sees update immediately
### Verify Sync
```sql
-- Check synced subscription
SELECT * FROM stripe_subscriptions;
-- Check synced customer
SELECT * FROM stripe_customers;
-- Check profile updated
SELECT email, is_paying, subscription_tier FROM profiles;
```
## 📝 Environment Variables Needed
```env
# Required
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
DATABASE_URL=postgresql://postgres:password@host:port/db
# Optional (if DATABASE_URL not set)
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJxxx...
```
## 🔧 Backfilling Historical Data
If you have existing Stripe data, backfill it:
```typescript
// Run this once to sync all existing data
await stripeSync.syncBackfill({
object: 'all', // or specific: 'customer', 'subscription', 'product', etc.
created: {
gte: 0 // Sync everything from the beginning
}
});
```
Or sync specific objects:
```typescript
await stripeSync.syncProducts();
await stripeSync.syncPrices();
await stripeSync.syncCustomers();
await stripeSync.syncSubscriptions();
```
## 📚 Resources
- **Library Repo**: https://github.com/supabase/stripe-sync-engine
- **Library Docs**: https://supabase.github.io/stripe-sync-engine
- **Our Implementation**: `api/src/stripe.ts`
- **Custom SQL**: `sql/35_stripe_wrappers.sql` (profile fields & triggers only)
## ⚡ Benefits Over Custom Implementation
| Feature | Custom | stripe-sync-engine |
|---------|--------|-------------------|
| Webhook handlers | Manual | ✅ Automatic |
| Schema management | Manual | ✅ Automatic |
| Edge cases | You handle | ✅ Handled |
| Updates | You maintain | ✅ Library updates |
| Testing | Your responsibility | ✅ Battle-tested |
| Backfilling | Custom scripts | ✅ Built-in |
## 🎉 Summary
**Before:** 267 lines of custom webhook code + SQL handlers
**After:** ~20 lines using the library
**What you maintain:**
- Profile integration (`is_paying`, `subscription_tier`)
- Action endpoints (checkout, portal, cancel)
- RLS policies
- Frontend hooks
**What the library handles:**
- Everything else! 🚀
---
**Status**: ✅ Integrated
**Next**: Configure Stripe, test webhooks, add to settings page

View file

@ -0,0 +1,320 @@
# 🎭 Testing Stripe with Fake Accounts - Super Simple Guide
## 🚀 Quick Start (5 Minutes)
### Step 1: Make Sure Stripe Test Mode is ON
In Stripe Dashboard, ensure you're in **TEST MODE** (toggle in top-right corner should say "Test mode").
### Step 2: Create "Standard" Product in Stripe
1. Go to **Products** in Stripe Dashboard
2. Click **Add product**
3. Name: `Standard`
4. Monthly price: `€9.99/month`
5. Click **Add product**
6. **Copy the price ID** (looks like `price_1O7...`)
7. Add to `apps/main/.env`:
```env
VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_YOUR_ID_HERE
```
### Step 3: Start Everything
**Terminal 1 - API:**
```bash
cd api
npm run dev
```
**Terminal 2 - Frontend:**
```bash
cd apps/main
npm run dev
```
**Terminal 3 - Stripe Webhooks:**
```bash
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
```
Copy the webhook secret (`whsec_...`) and add to `api/.env`:
```env
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET_HERE
```
Restart your API (Terminal 1).
### Step 4: Create Fake Test Accounts
Create 3 test users in your app:
**Account 1: Free User (stays free)**
```
Email: free.user@test.com
Password: TestPass123!
Name: Free User
```
**Account 2: Premium User (will subscribe)**
```
Email: premium.user@test.com
Password: TestPass123!
Name: Premium User
```
**Account 3: Canceling User (subscribe then cancel)**
```
Email: cancel.user@test.com
Password: TestPass123!
Name: Cancel User
```
## 🧪 Testing Scenarios
### Test 1: Free User ✅
1. **Login** as `free.user@test.com`
2. **Go to Settings**
3. **You should see:**
- Badge: "Gratuit"
- Message: "Passez à Standard..."
- Button: "Passer à Standard"
**Verify in database:**
```sql
SELECT email, is_paying, subscription_tier
FROM profiles
WHERE email = 'free.user@test.com';
-- Should show: is_paying = false, subscription_tier = 'free'
```
### Test 2: Subscribe to Standard 💳
1. **Login** as `premium.user@test.com`
2. **Go to Settings**
3. **Click "Passer à Standard"**
4. **In Stripe Checkout, enter:**
```
Email: premium.user@test.com
Card: 4242 4242 4242 4242
Expiry: 12/34
CVC: 123
Name: Premium User
```
5. **Click Subscribe**
6. **Watch Terminal 3** - You should see:
```
✓ customer.created
✓ customer.subscription.created
✓ invoice.created
✓ invoice.paid
✓ payment_intent.succeeded
```
7. **You'll be redirected back to Settings**
8. **You should see:**
- Badge: "Actif" (green)
- Message: "Plan Standard"
- Renewal date displayed
- Buttons: "Gérer l'abonnement" and "Annuler"
**Verify in database:**
```sql
-- Check profile updated
SELECT email, is_paying, subscription_tier
FROM profiles
WHERE email = 'premium.user@test.com';
-- Should show: is_paying = true, subscription_tier = 'standard'
-- Check subscription exists
SELECT status, current_period_end, cancel_at_period_end
FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'premium.user@test.com');
-- Should show: status = 'active', cancel_at_period_end = false
-- Check customer exists
SELECT * FROM stripe_customers
WHERE email = 'premium.user@test.com';
```
### Test 3: Customer Portal 🏪
1. **Still logged in** as `premium.user@test.com`
2. **Click "Gérer l'abonnement"**
3. **Opens Stripe Customer Portal** where you can:
- View/download invoices
- Update payment method
- View subscription details
- Cancel subscription
### Test 4: Cancel Subscription ❌
1. **In your app** (as `cancel.user@test.com`)
2. **Subscribe first** (follow Test 2)
3. **Click "Annuler"** button
4. **Watch Terminal 3:**
```
✓ customer.subscription.updated
```
5. **You should see:**
- Orange warning: "Abonnement en cours d'annulation"
- Message: "Sera annulé le [date]"
- Button: "Réactiver l'abonnement"
**Verify in database:**
```sql
SELECT cancel_at_period_end, current_period_end
FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'cancel.user@test.com');
-- Should show: cancel_at_period_end = true
-- Note: is_paying should STILL be true until period ends
SELECT is_paying FROM profiles WHERE email = 'cancel.user@test.com';
-- Should show: is_paying = true (until period_end)
```
### Test 5: Reactivate Subscription ↩️
1. **Click "Réactiver l'abonnement"**
2. **Watch Terminal 3:**
```
✓ customer.subscription.updated
```
3. **Should return to normal active state**
**Verify in database:**
```sql
SELECT cancel_at_period_end
FROM stripe_subscriptions
WHERE user_id = (SELECT id FROM profiles WHERE email = 'cancel.user@test.com');
-- Should show: cancel_at_period_end = false
```
## 🎯 Quick Verification Script
Run this in Supabase SQL Editor after testing:
```sql
-- View all test users and their subscriptions
SELECT
p.email,
p.is_paying,
p.subscription_tier,
s.status as sub_status,
s.cancel_at_period_end as canceling,
to_char(s.current_period_end, 'YYYY-MM-DD') as expires
FROM profiles p
LEFT JOIN stripe_subscriptions s ON s.user_id = p.id
WHERE p.email LIKE '%test.com'
ORDER BY p.created_at DESC;
```
Expected results:
```
free.user@test.com | false | free | null | null | null
premium.user@test.com | true | standard | active | false | 2025-12-02
cancel.user@test.com | true | standard | active | true | 2025-12-02
```
## 🎴 Test Card Cheat Sheet
| Scenario | Card Number | Use For |
|----------|-------------|---------|
| ✅ Success | `4242 4242 4242 4242` | Normal subscription |
| ❌ Decline | `4000 0000 0000 0002` | Test payment failure |
| 💳 3D Secure | `4000 0027 6000 3184` | Test authentication |
| 🏦 Insufficient | `4000 0000 0000 9995` | Test insufficient funds |
**For all cards:**
- Expiry: Any future date (e.g., `12/34`)
- CVC: Any 3 digits (e.g., `123`)
- ZIP: Any 5 digits (e.g., `12345`)
## 🐛 Troubleshooting
### "Passer à Standard" button doesn't work
**Check:**
```typescript
// In browser console:
console.log(import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID);
// Should show: price_xxxxx
```
If undefined, add to `apps/main/.env` and restart frontend.
### Webhook not received
**Terminal 3 should show:**
```
⣾ Ready! Your webhook signing secret is whsec_xxxxx
```
If not, run:
```bash
stripe listen --forward-to http://localhost:3000/api/v1/stripe/webhook
```
### is_paying not updating
**Check webhook was processed:**
```bash
# In Terminal 3, you should see:
✓ customer.subscription.created [200]
```
**Manually trigger update:**
```sql
-- Force update profile
UPDATE profiles
SET is_paying = true, subscription_tier = 'standard'
WHERE email = 'premium.user@test.com';
```
### Can't see SubscriptionCard
**Add to settings.tsx:**
```typescript
import { SubscriptionCard } from "../components/SubscriptionCard";
// In the cards section:
<SubscriptionCard />
```
## ✅ Success Checklist
After testing all 3 accounts, you should have:
- [ ] Free user shows upgrade prompt
- [ ] Premium user shows active subscription
- [ ] Webhook events appear in Terminal 3
- [ ] Database shows correct subscription data
- [ ] `is_paying` updates automatically
- [ ] Can cancel subscription
- [ ] Cancellation shows warning
- [ ] Can reactivate subscription
- [ ] Customer portal opens correctly
- [ ] Each user only sees their own data
## 🎉 You're Done!
If all checks pass, your Stripe integration is working perfectly!
**Next steps:**
1. Test with more edge cases (different cards, failed payments)
2. Add subscription checks to premium features
3. Build pricing page
4. Prepare for production deployment
## 🔗 More Information
- **Complete Setup**: `docs/STRIPE_SETUP.md`
- **Quick Reference**: `docs/STRIPE_QUICK_REFERENCE.md`
- **Detailed Testing**: `docs/STRIPE_TESTING_GUIDE.md`
- **API Documentation**: `docs/STRIPE_IMPLEMENTATION_SUMMARY.md`
---
**Pro Tip**: Keep these test accounts for future testing. You can reset them by deleting subscriptions in Stripe Dashboard (Test Mode → Subscriptions).