| name | stripe |
| description | Intégration Stripe complète pour SaaS : Checkout, Subscriptions, Webhooks, Customer Portal. Best practices officielles, prêt pour la production. |
| argument-hint | - `mode` : checkout | webhooks | subscriptions | portal | setup |
| disable-model-invocation | true |
Arguments
mode : checkout | webhooks | subscriptions | portal | setup | full
/stripe setup — Installation & configuration initiale
pnpm add stripe @stripe/stripe-js
pnpm add -D @types/stripe
Variables .env.example à ajouter :
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Client Stripe singleton src/adapters/payments/stripe.ts :
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2026-03-31',
typescript: true,
})
CLI local — écouter les webhooks :
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe
Tester des events :
stripe trigger invoice.paid
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed
📖 Docs : https://docs.stripe.com/stripe-cli
/stripe checkout — Checkout Session (recommandé pour SaaS)
Utiliser Checkout Sessions (pas Payment Intents) sauf besoin de contrôle granulaire.
Checkout gère automatiquement : taxes, promos, subscriptions, 3D Secure.
Route API app/api/checkout/route.ts :
import { stripe } from '@/adapters/payments/stripe'
import { auth } from '@/adapters/auth'
import { NextResponse } from 'next/server'
import { z } from 'zod'
const Body = z.object({ priceId: z.string() })
export async function POST(req: Request) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { priceId } = Body.parse(await req.json())
const checkout = await stripe.checkout.sessions.create({
customer_email: session.user.email,
client_reference_id: session.user.id,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=1`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=1`,
automatic_tax: { enabled: true },
billing_address_collection: 'auto',
allow_promotion_codes: true,
})
return NextResponse.json({ data: { url: checkout.url } })
}
Côté client :
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId: 'price_xxx' }),
})
const { data } = await res.json()
window.location.href = data.url
Cartes de test :
| Carte | Résultat |
|---|
4242 4242 4242 4242 | Succès |
4000 0000 0000 0002 | Refusée |
4000 0025 0000 3155 | 3D Secure requis |
4000 0000 0000 9995 | Fonds insuffisants |
📖 Docs : https://docs.stripe.com/payments/checkout-sessions-and-payment-intents-comparison
/stripe webhooks — Webhooks (critique pour SaaS)
⚠️ Utiliser le body RAW (pas parsé en JSON) pour la vérification de signature.
Stocker event.id en DB pour l'idempotence (éviter les doubles exécutions).
Route app/api/webhooks/stripe/route.ts :
import { stripe } from '@/adapters/payments/stripe'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
export const config = { api: { bodyParser: false } }
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
const alreadyProcessed = await db.stripeEvents.findUnique({ where: { id: event.id } })
if (alreadyProcessed) return NextResponse.json({ received: true })
try {
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case 'invoice.paid':
await handleInvoicePaid(event.data.object as Stripe.Invoice)
break
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice)
break
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription)
break
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object as Stripe.Subscription)
break
}
await db.stripeEvents.create({ data: { id: event.id, type: event.type } })
} catch (err) {
return NextResponse.json({ error: 'Handler failed' }, { status: 500 })
}
return NextResponse.json({ received: true })
}
Events critiques à écouter (SaaS) :
| Event | Action |
|---|
checkout.session.completed | Créer/lier customer + subscription en DB |
invoice.paid | ✅ Activer l'accès / renouveler |
invoice.payment_failed | ⚠️ Restreindre l'accès, notifier |
customer.subscription.updated | Sync plan en DB |
customer.subscription.deleted | Désactiver l'accès |
📖 Docs : https://docs.stripe.com/webhooks/handling-payment-events | https://docs.stripe.com/webhooks/signature
/stripe subscriptions — Gestion des abonnements
États possibles :
trialing → Essai gratuit en cours
active → Abonnement actif et à jour
past_due → Paiement échoué, retry en cours
paused → Suspendu
canceled → Annulé
Upgrade / Downgrade avec proration :
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscriptionItemId, price: 'price_premium' }],
proration_behavior: 'always_invoice',
})
Créer avec période d'essai :
await stripe.subscriptions.create({
customer: customerId,
items: [{ price: 'price_xxx' }],
trial_period_days: 14,
})
Annuler (fin de période vs immédiat) :
await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
})
await stripe.subscriptions.cancel(subscriptionId)
📖 Docs : https://docs.stripe.com/billing/subscriptions/overview | https://docs.stripe.com/billing/subscriptions/upgrade-downgrade
/stripe portal — Customer Portal
Route app/api/billing/portal/route.ts :
import { stripe } from '@/adapters/payments/stripe'
import { auth } from '@/adapters/auth'
import { NextResponse } from 'next/server'
export async function POST() {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const user = await db.users.findUnique({ where: { id: session.user.id } })
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: 'No billing account' }, { status: 404 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
})
return NextResponse.json({ data: { url: portal.url } })
}
Le Customer Portal gère automatiquement : changement de plan, annulation, mise à jour CB, historique des factures.
📖 Docs : https://docs.stripe.com/customer-management/integrate-customer-portal
Schema DB recommandé (Drizzle)
export const subscriptions = pgTable('subscriptions', {
id: uuid('id').defaultRandom().primaryKey(),
organizationId: uuid('organization_id').references(() => organizations.id),
stripeCustomerId: text('stripe_customer_id').unique(),
stripeSubscriptionId: text('stripe_subscription_id').unique(),
stripePriceId: text('stripe_price_id'),
stripeCurrentPeriodEnd: timestamp('stripe_current_period_end'),
status: text('status'),
plan: text('plan'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
})
export const stripeEvents = pgTable('stripe_events', {
id: text('id').primaryKey(),
type: text('type'),
processedAt: timestamp('processed_at').defaultNow(),
})
Checklist pré-production
📖 Docs officielles complètes : https://docs.stripe.com/billing/subscriptions/build-subscriptions