xtablo-source/docs/STRIPE_ARCHITECTURE.md
Arthur Belleville 7bb90becb9
IA docs
2025-11-03 09:46:10 +01:00

8.6 KiB

Stripe Integration Architecture

🏗️ Simplified Webhook-Based Architecture

Overview

This integration uses a pure webhook-based approach without Foreign Data Wrappers:

┌─────────────┐
│   Stripe    │ (Source of truth)
└──────┬──────┘
       │ Webhooks
       ↓
┌─────────────┐
│  Node.js    │ (Webhook processor)
│     API     │
└──────┬──────┘
       │ SQL Functions
       ↓
┌─────────────┐
│  Supabase   │ (Data storage)
│   Database  │
└──────┬──────┘
       │ Direct queries (RLS)
       ↓
┌─────────────┐
│  Frontend   │ (React app)
│   (Vite)    │
└─────────────┘

📊 Data Flow Patterns

Read Pattern (Fast )

Frontend → Supabase Client → RLS Policies → Database Tables

Examples:

  • Check if user is paying: user.is_paying
  • Get subscription: useSubscription() → 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

- id (PK)
- user_id (FK  auth.users) 
- stripe_customer_id (unique)
- email

stripe_subscriptions

- id (PK)
- user_id (FK  auth.users)
- stripe_customer_id (FK)
- status (active, trialing, canceled, etc.)
- price_id
- current_period_start
- current_period_end
- cancel_at_period_end

stripe_products

- id (PK)
- name ("Standard")
- active
- description

stripe_prices

- id (PK)
- product_id (FK)
- unit_amount (in cents)
- currency
- interval (month, year)

profiles (extended)

- is_paying (boolean)  Auto-updated by trigger
- subscription_tier (text)  Auto-updated by trigger

RLS Policies

-- Users see only their own data
stripe_customers: WHERE auth.uid() = user_id
stripe_subscriptions: WHERE auth.uid() = user_id

-- Products/prices are public (for pricing page)
stripe_products: WHERE true
stripe_prices: WHERE true

Automatic Triggers

-- When subscription changes → Update profile
CREATE TRIGGER update_profile_on_subscription_change
  AFTER INSERT OR UPDATE ON stripe_subscriptions
   Updates profiles.is_paying and profiles.subscription_tier

🔄 Webhook Flow

1. Stripe Event Occurs

User subscribes → Stripe creates subscription

2. Webhook Sent

POST https://your-api.com/api/v1/stripe/webhook
Headers: stripe-signature
Body: {
  type: "customer.subscription.created",
  data: { object: { ...subscription } }
}

3. API Processes Event

// stripe-webhook.ts
1. Verify signature
2. Parse event type
3. Call appropriate database function

4. Database Function Executes

-- handle_stripe_subscription_upsert()
1. Upsert subscription record
2. Trigger fires automatically
3. Profile updated (is_paying = true)

5. Frontend Sees Update

// Next time query runs
const { data: subscription } = useSubscription();
// Returns updated data with RLS filtering

🔐 Security Model

Frontend → Database

Supabase Client (anon key)
  → RLS Policies
    → Only own user_id data
      → Read-only access

Frontend CANNOT:

  • Modify subscription data
  • See other users' subscriptions
  • Delete customer records

Frontend CAN:

  • Read own subscription
  • Read public products/prices
  • Call API endpoints for actions

API → Database

Supabase Client (service role key)
  → Full database access
    → Used only in webhook handlers
      → Validates Stripe signature first

API CAN:

  • Insert/update via webhook functions
  • Query any subscription for operations
  • Execute privileged database functions

Webhook → Database

Stripe Webhook
  → Signature verified
    → Service role functions
      → Bypass RLS for writes

🎯 Why This Architecture?

No Foreign Data Wrappers

Reasons:

  • Extra complexity
  • Requires Vault configuration
  • API rate limits apply
  • Slower than cached data
  • Webhooks provide all needed data

Direct Supabase Access

Benefits:

  • Performance: No API hop for reads
  • 🔒 Security: RLS enforces data access
  • 🎯 Simplicity: Standard Supabase patterns
  • 📊 Real-time: Can use Supabase subscriptions
  • 🔌 Offline: Can cache subscription data

API for Actions Only

Why:

  • 🔑 Secret management: API has Stripe secret key
  • Validation: Server-side validation before Stripe calls
  • 🎫 Checkout: Creates secure checkout sessions
  • 🔐 Signatures: Verifies webhook signatures

📈 Performance Characteristics

Query Performance

Check if user is paying:

user.is_paying  // Instant (already in memory)

Get subscription details:

useSubscription()  
// ~50-100ms (Supabase query with join)
// Cached for 5 minutes

Start checkout:

useCreateCheckoutSession()
// ~500-1000ms (API → Stripe → Redirect)

Webhook Processing

Typical webhook flow:

Stripe event → API receives → DB function → Trigger
Total: ~100-300ms

Database trigger:

Subscription updated → Profile updated
Total: ~10-50ms

🎨 Frontend Usage Patterns

Pattern 1: Simple Payment Check

const user = useUser();
if (!user.is_paying) {
  return <PaywallComponent />;
}
return <PremiumFeature />;

Pattern 2: Detailed Subscription Info

const { data: subscription, isLoading } = useSubscription();

if (isLoading) return <Spinner />;
if (!subscription) return <UpgradePrompt />;

return (
  <div>
    Status: {subscription.status}
    Renews: {subscription.current_period_end}
    {subscription.cancel_at_period_end && <CancellationWarning />}
  </div>
);

Pattern 3: Pricing Page

const { data: prices } = useStripePrices();
const { mutate: checkout } = useCreateCheckoutSession();

return prices?.map(price => (
  <PriceCard
    key={price.id}
    amount={price.unit_amount}
    interval={price.interval}
    onSelect={() => checkout({ priceId: price.id })}
  />
));

🔧 Maintenance

Syncing Products/Prices

Products and prices are synced automatically via webhooks when you:

  1. Create/update product in Stripe Dashboard
  2. Webhook fires → Syncs to stripe_products table
  3. Frontend queries updated pricing

Monitoring Subscriptions

-- View current user's active subscription (secure, RLS-compliant)
SELECT * FROM get_my_active_subscription();

-- Check for subscription status distribution (requires direct stripe schema access)
SELECT 
  status::text,
  COUNT(*) as count
FROM stripe.subscriptions
GROUP BY status;

-- Find subscriptions ending soon (admin query)
SELECT 
  p.email,
  si.current_period_end
FROM stripe.subscriptions s
JOIN stripe.customers c ON c.id = s.customer
JOIN stripe.subscription_items si ON si.subscription = s.id
JOIN profiles p ON p.id = (c.metadata->>'user_id')::uuid
WHERE to_timestamp(si.current_period_end) < NOW() + INTERVAL '7 days'
  AND s.status::text = 'active';

📝 Summary

This architecture provides:

  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