Explorer
KNOW-PAT-232

Payment & Billing Architecture — Stripe, subscriptions, invoices, reconciliation

Domaine
finance
Type
pattern
Priorité
P1

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 float pour des montants — utiliser Decimal / 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 toutuserId, orderId pour 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

  • float pour 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)