Aller au contenu

Note d'ingénierie

Paris fictifs, résultats réels : régler sans jamais payer deux fois

Par Alexandre Kaczor7 min de lecture
  • NestJS
  • Architecture
  • Tests
Illustration de l'article « Paris fictifs, résultats réels : régler sans jamais payer deux fois »

Dans un simulateur de paris sans argent réel, une erreur de règlement fausse quand même le classement. Claim conditionnel, aucun verdict deviné, ingestion des résultats : le code qui tranche.

ZeroStake simule l'expérience d'un site de paris sportifs : cotes, ticket, portefeuille, classement entre amis. Il n'y a aucun argent réel, ni dépôt, ni retrait. La devise s'appelle EUR_FAKE. On pourrait en conclure que le règlement des paris n'a pas besoin d'être rigoureux. C'est faux. Un pari gagnant marqué perdu fausse le classement. Un gain crédité deux fois gonfle un portefeuille, et un pari bloqué « en attente » pendant une semaine donne l'impression d'une application cassée. Le code qui tranche les paris a donc les mêmes exigences que dans un vrai produit financier, mais sans l'excuse de la complexité réglementaire.

Le cadre passe avant le code

Le positionnement est écrit dans le dépôt avant toute fonctionnalité : application fictive, éducative et sociale, qui ne permet jamais de miser de l'argent réel. Les interdits du produit sont listés noir sur blanc : paiement, dépôt, retrait ou cashout réels, promesse de profit, lien agressif vers un opérateur, langage addictif (« pari sûr », « gain garanti », « all-in »).

Dans l'interface, cela donne trois choses concrètes :

  • Une porte 18+ au premier passage, qu'on ne peut pas fermer avec Échap, et un lien « Je suis mineur — quitter ».
  • Un bandeau permanent « SIMULATION FICTIVE — AUCUN ARGENT RÉEL », qui précise que ZeroStake n'est pas un opérateur de jeux d'argent, que les jeux d'argent réels peuvent créer une dépendance et qui renvoie vers la ligne d'aide nationale.
  • Des libellés honnêtes jusque dans le grand livre : chaque mouvement de portefeuille est typé FAKE_BET_STAKE, FAKE_BET_WIN ou FAKE_BET_REFUND, et sa description commence par « Gain fictif » ou « Remboursement fictif ».

Trois régleurs pour un seul pari

Un pari peut être tranché par trois chemins : un cron qui passe toutes les 30 minutes sur les paris en attente, l'ingestion des résultats réels toutes les 15 minutes, et le règlement manuel depuis le back-office. Ces chemins peuvent tomber sur le même pari au même moment. Sans garde, le portefeuille est crédité deux fois.

La garde tient en une requête. On ne lit pas le statut puis on écrit : on écrit à condition que le pari soit encore en attente, et c'est la base qui arbitre.

async updateSettlement(id: string, status: BetStatus, settledAt: Date,
  tx?: Prisma.TransactionClient, actualReturn?: number): Promise<boolean> {
  const { count } = await (tx ?? this.prisma).bet.updateMany({
    where: { id, status: 'PENDING' },
    data: { status, settledAt, ...(actualReturn !== undefined ? { actualReturn } : {}) },
  });
  return count === 1;
}

Chaque régleur l'appelle en premier, dans la même transaction que le crédit :

await this.prisma.$transaction(async (tx) => {
  // Claim : si un règlement concurrent a déjà pris ce pari, on abandonne
  const claimed = await this.betRepo.updateSettlement(bet.id, finalStatus, now, tx, settlementReturn);
  if (!claimed) return;
  // ... écriture des jambes, audit, puis creditForWin ou refundBet
});

Le même motif protège les jambes d'un combiné (where: { id: legId, outcome: 'PENDING' }) : une jambe annulée par un report puis rejouée ne doit jamais être réécrite en gagnée ou perdue, ce qui effacerait un remboursement déjà accordé.

Ce motif n'est pas théorique. L'annulation d'un pari lisait d'abord le statut hors transaction, puis remboursait. Six requêtes d'annulation simultanées sur le même pari passaient toutes le contrôle : 10 € fictifs misés, 60 € rendus, à volonté. La correction est la même requête conditionnelle, et une seule des six obtient count === 1.

Ce qui suit le règlement est volontairement détaché : l'annonce du gain aux abonnés et le calcul des succès tournent hors transaction, avec un .catch qui journalise. Une notification qui échoue ne doit pas annuler un crédit déjà validé.

Ne jamais deviner un résultat

La première version avait un défaut que je préfère raconter : pour la majorité du catalogue, aucune source de résultats n'existait, et un Math.random() pondéré par la probabilité implicite de la cote tranchait les paris restés sans résultat. Un joueur pouvait perdre alors que son équipe avait gagné. Le classement et la fiabilité affichée des analyses IA étaient nourris de ce bruit.

Le tirage a disparu. La règle est maintenant : on applique les résultats qui existent, on n'en invente aucun. Trois délais encadrent l'attente :

export const MIN_MATCH_DURATION_MS = 90 * 60 * 1_000;
export const REAL_RESULTS_GRACE_MS = 12 * 60 * 60 * 1_000;
export const UNRESOLVED_REFUND_MS = 7 * MS_PER_DAY_CONST;

Pas de règlement avant 90 minutes après le coup d'envoi. Jusqu'à 12 heures, on attend le résultat réel. Au-delà de 7 jours sans résultat, la jambe passe en annulée et la mise est rendue en totalité. Entre les deux, le règlement manuel reste ouvert au back-office. Rembourser n'invente rien et ne lèse personne.

Plusieurs cas de bord suivent la même logique :

  • Libellé incohérent avec le marché (un libellé de handicap sous une clé « 1N2 », par exemple) : la jambe est marquée intranchable et le pari reste en attente. Le comparateur aurait rendu un verdict arbitraire.
  • Joueur remplacé avant le match sur un marché à cote estimée : la jambe est annulée tout de suite, jamais perdue.
  • Non-partant en course hippique : jambe annulée, mise rendue.
  • Combiné déjà perdu : une jambe perdue suffit à régler le pari perdu, même si une autre attend encore son score. Avant ce correctif, un combiné avec deux jambes perdues restait « en attente » plusieurs jours.

Ingérer les résultats : l'appariement est le vrai problème

Les scores viennent du tableau de scores public d'ESPN, interrogé toutes les 15 minutes pour la veille et le jour même. Les arrivées hippiques viennent des arrivées définitives publiées par le PMU. Récupérer un score est facile. Le difficile est de le rattacher à la bonne rencontre en base, parce que les cotes viennent d'une autre source qui n'écrit pas les noms de la même façon.

L'ingestion essaie donc trois chemins, dans l'ordre : l'identifiant du fournisseur, puis une clé métier de rencontre avec une tolérance de deux heures sur l'heure de début (un double match de baseball met deux rencontres sous la même clé), puis l'appariement par noms d'équipes. Ce dernier chemin a été ajouté après une mesure en production : 98 % des rencontres ingérées depuis ESPN obtenaient un score, contre 29 % de celles de l'autre source. Le résultat existait, mais « Asteras Tripolis » ne correspondait pas à « Asteras Tripoli ». La fonction d'appariement par nom refuse de trancher dès que deux candidates se valent : ne pas régler se répare, alors que régler le mauvais match ne se répare pas.

Deux autres gardes complètent l'appariement. Quand un match est trouvé avec domicile et extérieur inversés, le score est remis dans l'ordre de notre rencontre avant toute écriture. Et une rencontre déjà annulée ne repasse jamais en terminée, même si un correctif tardif arrive.

Enfin, la passe nominale n'avait aucune reprise. Un match manqué pendant sa fenêtre de deux jours (403 du fournisseur, API redémarrée) n'était plus jamais redemandé. Mesuré en production : 28 paris gelés et 4 321 € fictifs immobilisés. Une reprise horaire part désormais des paris réellement bloqués (commencés depuis plus de 6 heures, dans un horizon de 30 jours) et ne redemande que ces jours-là. Quand rien n'est bloqué, ce qui est le cas normal, elle ne fait aucun appel. Le cron de règlement lit aussi directement le score en base, quel que soit le chemin par lequel il est arrivé.

Ce qui reste fragile

L'idempotence repose entièrement sur le claim conditionnel et sur la transaction qui l'entoure. Le grand livre lui-même n'a pas de contrainte d'unicité sur le couple pari et type de mouvement : un futur chemin de crédit qui oublierait d'appeler le claim ne serait arrêté par rien en base. C'est un filet que je n'ai pas encore posé. Les tests couvrent en revanche les cas qui ont réellement cassé : claim perdu sans mouvement de portefeuille, non-partant, combiné perdu en avance, participant remplacé, marchés de buts sans score.

Ce que j'en retiens

  • Argent fictif ne veut pas dire règlement approximatif. Le classement et la confiance des joueurs reposent sur l'exactitude, pas sur la devise.
  • La base arbitre la concurrence. Un updateMany conditionné au statut et un count === 1 remplacent toute lecture suivie d'une écriture.
  • Ne pas trancher vaut mieux que deviner. Attendre, puis rembourser, ne crée jamais de perdant injuste.
  • L'appariement des résultats mérite plus d'attention que leur récupération. Il faut mesurer le taux de rattachement : c'est lui qui révèle les paris oubliés.

Vous avez un moteur de règles, un portefeuille ou un traitement concurrent à fiabiliser ? Écrivez-moi, on regarde ensemble où il peut payer deux fois.