Pour éviter la survente de billets avec PostgreSQL, compter les places vendues ne suffit pas : il faut les compter sous verrou. Sur une billetterie événementielle (API NestJS 10.4, Prisma 5.22, Stripe), la capacité d’un événement était saisie par l’organisateur, affichée dans l’app (« 3 restants »)… et vérifiée nulle part : un événement à 50 places pouvait en vendre 500. Le correctif tient en une ligne SQL, SELECT … FOR UPDATE sur l’événement, plus une décision produit sur ce qu’on fait d’un paiement qui arrive trop tard.
Symptôme : une jauge décorative
La jauge est le nombre maximal de billets vendables pour un événement (events.capacity). Elle servait uniquement à l’affichage. Même chose pour le quota par tarif (ticket_tiers.quantity) : l’API l’acceptait, le stockait, et ne le lisait jamais. Un « Early Bird limité à 50 » se vendait sans limite.
Ajouter un count() avant la création des billets ne règle rien. Deux acheteurs qui visent la dernière place exécutent le même comptage au même moment, voient tous les deux « une place libre », et créent chacun leur billet. C’est une course classique lecture puis écriture : la vérification n’a de valeur que si personne ne peut vendre entre le comptage et l’insertion.
Éviter la survente de billets : SELECT … FOR UPDATE sur l’événement
SELECT … FOR UPDATE pose un verrou de ligne : toute autre transaction qui demande le même verrou attend le COMMIT ou le ROLLBACK de la première. Prisma n’expose pas ce verrou dans son API de requêtes, d’où le $queryRaw dans une transaction interactive :
export async function assertSeatsAvailable(tx: Tx, eventId: string, quantity: number) {
// Verrouille l'événement jusqu'à la fin de la transaction
const rows = await tx.$queryRaw<{ capacity: number | null }[]>`
SELECT capacity FROM events WHERE id = ${eventId}::uuid FOR UPDATE`;
const capacity = rows[0]?.capacity ?? null;
// Un billet occupe une place s'il n'est ni rejeté, ni annulé, ni remboursé
const taken = await tx.ticket.count({ where: seatsTakenWhere(eventId) });
if (capacity && taken + quantity > capacity) {
throw new SoldOutException(capacity - taken); // 409 Conflict
}
}
Le second acheteur attend, puis compte une place de moins et reçoit une erreur exploitable par l’app :
{ "statusCode": 409, "code": "SOLD_OUT", "message": "Not enough seats left for this event", "remaining": 0 }
Le quota par tarif réutilise le même verrou : comme tous les achats d’un événement passent déjà par la ligne events, il suffit d’ajouter un second comptage filtré sur tier_id juste après, avec une erreur TIER_SOLD_OUT. Un seul verrou sérialise la jauge globale et tous les tarifs.
Le filtre « places occupées » cache lui-même un piège sur les colonnes NULL en Prisma ; il fera l’objet d’un article à part.
Une vérification de stock n’est une garantie que si le comptage et l’insertion se font dans la même transaction, derrière le même verrou.
Pourquoi un verrou de ligne plutôt qu’un advisory lock ?
Pour les doublons de numéros de facture, j’avais utilisé un advisory lock parce qu’aucune ligne ne représentait la séquence à protéger. Ici, la ligne naturelle existe : l’événement. Verrouiller events suffit, sans clé de hachage à inventer.
Trois chemins créent des billets : la réservation, l’émission de billets gratuits et la délivrance d’un paiement Stripe. Les trois appellent la même garde, dans leur transaction, avant le premier INSERT.
Et si la dernière place part pendant le paiement ?
Avec Stripe, l’achat se fait en deux temps : création du PaymentIntent, puis fulfillment (délivrance des billets) après le succès du paiement. La décision produit : ne pas réserver la place à la création du paiement.
| Option | Avantage | Coût |
|---|---|---|
| Réserver à la création du paiement | Jamais de remboursement | Places bloquées par les paniers abandonnés, expiration à gérer |
| Vérifier sans verrou, trancher au fulfillment | Aucune place gelée | Rare paiement à rembourser |
Concrètement, la création du paiement compte les places sans verrou et refuse avant tout débit dans le cas courant. Le fulfillment reprend le verrou FOR UPDATE : si la place est partie entre-temps, il ne crée aucun billet et renvoie soldOut: true. Ce fulfillment est déjà unique et idempotent, comme décrit dans le webhook Stripe et la confirmation client sous double advisory lock.
Reste à rendre l’argent, intégralement, virement vers l’organisateur et frais de plateforme compris :
await stripe.refunds.create(
{
payment_intent: intent.id,
amount: intent.amount,
// Paiement Connect : reprendre le virement et rendre les frais
...(intent.transfer_data ? { reverse_transfer: true, refund_application_fee: true } : {}),
},
{ idempotencyKey: `soldout:${intent.id}` },
);
La clé d’idempotence dérivée de l’intent compte : la confirmation côté client et le webhook peuvent constater la survente tous les deux. Avec la même clé, Stripe ne crée qu’un seul remboursement (doc idempotence Stripe).
Capacité 0 ou NULL : illimitée, mais acceptée
Une capacité absente ou à zéro signifie vente sans limite : la garde laisse tout passer. Ce choix a un revers, puisqu’un organisateur qui oublie de saisir la jauge vend sans plafond. Une migration a donc ajouté unlimited_capacity_accepted_at et unlimited_capacity_accepted_by sur events : ouvrir une vente sans limite exige une acceptation expresse, horodatée par le serveur.
Lessons learned
- Un champ affiché n’est pas un champ vérifié. Chercher où chaque limite saisie est réellement lue avant de la croire appliquée.
- Un
count()puis unINSERTsans verrou laisse passer la survente. Deux transactions concurrentes lisent le même stock ; seul un verrou tenu jusqu’au commit les sérialise. - Verrouiller la ligne parente quand elle existe.
FOR UPDATEsur l’événement couvre la jauge et tous les quotas de tarif, sans advisory lock ni clé à inventer. - Avec Prisma, le verrou vit dans
$queryRawau sein d’une transaction interactive. Tout ce qui suit doit utiliser le clienttx, sinon le verrou ne protège rien. - Ne pas réserver impose un chemin de remboursement. Le fulfillment tranche sous verrou et rembourse intégralement, avec une clé d’idempotence dérivée du PaymentIntent.