Accepter des paiements

Session Checkout, confirmation côté serveur et traitement d'un webhook Stripe.

core/payments enveloppe Stripe. Ce guide couvre le parcours complet d'un paiement unique par Checkout, qui est le plus court chemin vers un encaissement fiable.

Configuration

STRIPE_SECRET_KEY=sk_…
STRIPE_PUBLISHABLE_KEY=pk_…
STRIPE_WEBHOOK_SECRET=whsec_…
LRVO_CURRENCY=CAD

Conditionner l'interface plutôt que de laisser un appel échouer :

import { isEnabled } from '@lrvo/core/payments';

if (!isEnabled()) return null;   // le bouton de paiement n'est pas rendu

1. Créer la session

Depuis une route API ou une server action, jamais depuis le client : la clé secrète ne doit pas quitter le serveur.

import { createCheckoutSession } from '@lrvo/core/payments';
import { getPublicBaseUrl } from '@lrvo/core/shared/config';
import { apiSuccess } from '@lrvo/core/api';

async function handleCheckout(request, _params, { session }) {
  const { items } = await request.json();
  const base = getPublicBaseUrl();

  const checkout = await createCheckoutSession({
    lineItems: items.map((i) => ({ price: i.priceId, quantity: i.quantity })),
    successUrl: `${base}/commandes/succes?session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${base}/panier`,
    customerEmail: session.user.email,
    clientReferenceId: session.user.id,
    mode: 'payment',
  });

  return apiSuccess({ url: checkout.url });
}

Le client redirige ensuite vers checkout.url.

clientReferenceId porte l'identifiant interne qui permettra de rapprocher le paiement plus tard. Sans lui, il faut deviner à qui appartient une session.

Le jeton {CHECKOUT_SESSION_ID} est remplacé par Stripe dans l'URL de retour.

2. Confirmer au retour

Un retour navigateur n'est pas une preuve de paiement. L'utilisateur peut appeler l'URL de succès directement. Toujours relire la session côté serveur.

import { getCheckoutSession } from '@lrvo/core/payments';

export default async function SuccessPage({ searchParams }) {
  const { session_id } = await searchParams;
  const checkout = await getCheckoutSession(session_id);

  if (checkout.payment_status !== 'paid') {
    return <p>Le paiement n'est pas confirmé.</p>;
  }

  return <p>Merci pour votre achat.</p>;
}

Cette page affiche l'état. Elle ne doit pas être le seul endroit qui enregistre la commande : un utilisateur qui ferme l'onglet avant le retour paierait sans que rien ne soit enregistré.

3. Traiter le webhook

C'est le webhook qui fait foi. Il arrive même si l'utilisateur ne revient jamais sur le site.

L'endpoint est servi par le core, sur /lrvo/api/payments/stripe/webhook : il n'y a pas de route à écrire, et la vérification de signature est déjà faite. Il reste à dire quels évènements on traite.

// instrumentation.js, dans register(), après await initializeLrvo({ modules, theme })
const { registerStripeEventHandler } = await import('@lrvo/core/payments');

registerStripeEventHandler('checkout.session.completed', async (event) => {
  const session = event.data.object;
  await fulfillOrder({
    userId:    session.client_reference_id,
    sessionId: session.id,
    amount:    session.amount_total,
  });
});

L'abonnement se pose au démarrage, pas dans un handler de requête : posé plus tard, il n'existerait que dans le processus qui a servi cette requête-là.

Trois points à ne pas rater :

Le traitement doit être idempotent. Stripe peut livrer le même évènement plusieurs fois. Enregistrer l'identifiant d'évènement traité, ou poser une contrainte d'unicité sur la commande, sinon un client sera livré deux fois.

Répondre rapidement. Stripe réessaie si la réponse tarde. Un traitement long se met en file plutôt que dans l'abonné.

Une exception vaut demande de retry. Un abonné qui lève produit un 500 et Stripe revient, pendant trois jours. Un échec définitif, une commande introuvable par exemple, se journalise et rend la main normalement.

Déclarer ensuite l'endpoint dans le tableau de bord Stripe. Voir Configuration.

PaymentIntent

Pour un formulaire de paiement intégré au site plutôt qu'une redirection.

import { createPaymentIntent } from '@lrvo/core/payments';

const intent = await createPaymentIntent({
  amount: 4999,                    // en centimes
  metadata: { orderId: order.id },
});
// transmettre intent.client_secret au client

Les montants sont en centimes. 4999 vaut 49,99 $. La devise vient de LRVO_CURRENCY si elle n'est pas précisée.

Clients

import { getOrCreateCustomer, listPaymentMethods } from '@lrvo/core/payments';

const customer = await getOrCreateCustomer(user.email, {
  name: user.name,
  metadata: { userId: user.id },
});

const cards = await listPaymentMethods(customer.id);

getOrCreateCustomer utilise une clé d'idempotence dérivée de l'adresse, ce qui limite les doublons en cas d'appels concurrents.

Rembourser

import { createRefund } from '@lrvo/core/payments';

await createRefund({
  paymentIntentId: order.payment_intent_id,
  amount: 1000,                      // partiel ; total si omis
  reason: 'requested_by_customer',
});

Pièges

Ne jamais faire confiance au montant envoyé par le client. Recalculer le total côté serveur à partir des identifiants de produits, sinon le prix est modifiable dans la requête.

Les clés de test et de production ne se mélangent pas. Une session créée avec une clé de test n'est pas lisible avec une clé de production.

Stocker un montant en entier de centimes, pas en flottant, si le total doit être exact.

Sur cette page