Payment & Billing Architecture
Problème
Les intégrations payment sont souvent bâclées : pas de gestion des webhooks, pas de reconciliation, pas de gestion des échecs de paiement, montants en float.
Solution
Stripe Checkout/PaymentIntents + webhooks idempotents + reconciliation + decimal partout.
1. Choisir l'API Stripe
| Use case | API Stripe | Pourquoi |
|---|---|---|
| Paiement simple one-shot | Checkout Sessions | Pas de PCI scope, UI hébergée |
| Paiement récurrent | Subscriptions + Checkout | Gestion cycle de vie par Stripe |
| Marketplace / multi-vendor | Connect (Express/Custom) | Split payments, KYC |
| Paiement in-app custom | PaymentIntents + Payment Element | Contrôle UI, 3DS obligatoire |
2. One-shot payment avec Checkout
// server — créer la session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'eur',
product_data: { name: 'Product Name' },
unit_amount: 2999, // 29.99 EUR en centimes (integer!)
},
quantity: 1,
}],
success_url: 'https://myapp.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://myapp.com/cancel',
customer_email: user.email,
metadata: { userId: user.id, orderId: order.id },
});
// redirect client vers session.url
3. Subscription
// Créer customer + subscription
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_xxx' }], // price ID depuis le dashboard
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
// Rediriger vers subscription.latest_invoice.payment_intent.client_secret
// pour confirmer le paiement côté client (Payment Element)
4. Webhooks — idempotents et sécurisés
// server — webhook handler
import { stripe } from '@/lib/stripe';
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature')!;
const raw = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return Response.json({ error: 'Invalid signature' }, { status: 400 });
}
// Idempotency: vérifier si déjà traité
const processed = await db.query.webhooks.findFirst({
where: eq(webhooks.eventId, event.id),
});
if (processed) return Response.json({ received: true });
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutComplete(event.data.object);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
}
// Marquer comme traité
await db.insert(webhooks).values({ eventId: event.id, type: event.type });
return Response.json({ received: true });
}
5. Règles financières critiques
- Jamais de
floatpour des montants — utiliserDecimal/BigInt/ centimes (integer) - Toujours stocker l'ID Stripe (customer, subscription, payment_intent)
- Webhooks idempotents — Stripe peut renvoyer le même event plusieurs fois
- Reconciliation journalière — comparer les transactions DB avec Stripe
- Pas de logique métier dans le webhook — ack rapide, traiter en async (queue)
- Metadata sur tout —
userId,orderIdpour tracer
6. Gestion des échecs
// Stripe retry logic automatique (Smart Retries)
// Configurer dans le dashboard:
// - 4 retries sur 3 semaines
// - Email customer sur échec
// - Dunning management emails
// Côté app: grader l'accès jusqu'à 3 échecs consécutifs
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const sub = await db.query.subscriptions.findFirst({
where: eq(subscriptions.stripeId, invoice.subscription as string),
});
if (!sub) return;
const failCount = sub.failCount + 1;
await db.update(subscriptions).set({
failCount,
status: failCount >= 3 ? 'suspended' : 'past_due',
}).where(eq(subscriptions.id, sub.id));
if (failCount >= 3) {
await sendEmail(sub.userId, 'payment-failed-final');
} else {
await sendEmail(sub.userId, 'payment-retry');
}
}
7. Invoices et taxes
// Stripe Tax — calcul automatique
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [...],
automatic_tax: { enabled: true }, // Stripe calcule la TVA
customer_details: {
address: { country: 'FR', postal_code: '75001' },
},
});
// Récupérer les invoices PDF
const invoices = await stripe.invoices.list({ customer: customer.id });
for (const inv of invoices.data) {
const pdf = await stripe.invoices.retrieve(inv.id);
// pdf.invoice_pdf — URL du PDF
}
Anti-patterns
floatpour des montants (erreurs d'arrondi)- Webhook sans vérification de signature
- Webhook sans idempotency (double traitement)
- Logique métier lourde dans le webhook (timeout)
- Pas de reconciliation DB vs Stripe
- Stocker des numéros de carte (PCI violation)
- Pas de gestion des échecs de paiement
- Metadata absente (impossible à tracer)
Références
- [[KNOW-PAT-224]] — Secure Auth Implementation
- [[KNOW-PAT-225]] — Secrets Management (Stripe keys)
- [[KNOW-PAT-221]] — Privacy by Design (RGPD financial data)
- [[KNOW-PAT-227]] — Database Schema Design (decimal types)