Un SaaS de gestion locative génère des documents numérotés : factures, quittances de loyer, reçus de paiement, régularisations de charges. Le numéro affiché sur chaque PDF (IN|60, RR|12, PR|7…) est censé être unique par propriétaire et par type de document — un compteur incrémental, classique.
En production, des locataires reçoivent deux PDF distincts portant le même numéro. Deux quittances « Numéro : 60 », baux différents, même propriétaire. Le bug a l’air anodin — un compteur qui se trompe. En réalité il touche trois couches : un tri SQL sans tie-breaker, une absence d’index unique, et — la partie la plus surprenante — une dette comptable qu’on ne peut pas effacer d’un UPDATE.
Symptôme
Le numéro est extrait d’une référence de la forme XX|Y portée par l’entité Document :
XX= préfixe de type :IN(facture/quittance),RR(quittance),PR(reçu),LR(régularisation).Y= compteur incrémental par propriétaire + par type.
Une requête d’audit suffit à mesurer l’ampleur. Un doublon = même owner_id et même reference, présents plus d’une fois :
SELECT owner_id, reference, COUNT(*) AS nb
FROM document
WHERE reference IS NOT NULL
GROUP BY owner_id, reference
HAVING COUNT(*) > 1;
Le résultat est édifiant : un propriétaire avec 16 factures mais un numéro maximum de 5. Un autre avec 75 documents partageant tous IN|5. Et l’illusion typique : une dizaine de quittances de baux différents d’un même propriétaire, toutes estampillées IN|3, toutes créées à la même seconde.
Cause : trois faiblesses qui s’additionnent
Le calcul du numéro suivant ressemblait à ça :
// "numéro suivant = numéro du dernier document créé + 1"
$last = $this->documentRepository->getLastDocumentWithReference($owner, $type);
$next = $this->extractNumber($last) + 1;
-- getLastDocumentWithReference()
SELECT * FROM document
WHERE owner_id = :owner AND reference LIKE 'IN%'
ORDER BY created_at DESC -- aucun second critère
LIMIT 1;
Trois problèmes se combinent, et c’est leur combinaison qui produit les doublons :
1. On prend « le dernier créé », pas « le plus grand numéro ». Le tri porte sur created_at, pas sur la valeur numérique de la référence. Or les deux ne coïncident pas toujours (régénération, reprise, import → un numéro élevé peut avoir un created_at antérieur).
2. created_at est en TIMESTAMP(0) — précision à la seconde. Toutes les factures générées dans la même seconde pour un propriétaire ont un created_at identique. ORDER BY created_at DESC LIMIT 1 renvoie alors une ligne arbitraire : il n’y a aucun tie-breaker. PostgreSQL est libre de te rendre n’importe laquelle des lignes ex æquo.
3. document.reference n’a aucun index unique. La base n’oppose donc aucun garde-fou. Rien n’empêche physiquement d’insérer deux fois IN|60.
Le scénario de collision le plus courant
Le cron nocturne boucle sur toutes les recettes à notifier et traite celles d’un même propriétaire en quelques millisecondes — donc dans la même seconde :
| Étape | ORDER BY created_at DESC LIMIT 1 renvoie | N° attribué | created_at |
|---|---|---|---|
| Facture A | IN|59 (la veille) | 60 | 10:00:00 |
| Facture B | IN|60 (seul à 10:00:00) | 61 | 10:00:00 |
| Facture C | IN|60 et IN|61 à 10:00:00, tie arbitraire → IN|60 | 61 ❌ | 10:00:00 |
→ B et C portent le numéro 61. Le doublon apparaît dès la 3ᵉ facture d’un même propriétaire dans la même seconde.
Deux autres scénarios existent : le numéro élevé créé plus tôt (déterministe, indépendant de la concurrence) et la concurrence pure (cron + génération à la demande lisant la même « dernière référence » avant que l’une ait committé). Mais le scénario « même seconde » est le plus fréquent en production.
Un détour par le git blame : l’origine était pire
En remontant l’historique, surprise : le tri d’origine n’était pas ORDER BY created_at mais ORDER BY created_by. Comme la requête filtre déjà WHERE owner_id = :owner, created_by est constant pour un propriétaire donné — et carrément NULL en contexte cron (pas d’utilisateur authentifié). Le tri était donc totalement inopérant : il renvoyait une facture arbitraire parmi toutes celles du propriétaire, pas seulement parmi celles de la même seconde.
Le passage ultérieur à created_at n’a pas aggravé le bug : il l’a réduit. Il a rétabli un vrai ordre temporel qui corrige le cas séquentiel courant, ne laissant subsister que le bug résiduel des égalités à la seconde. Contre-intuitif quand on cherche un coupable : le commit qui « touche » la ligne fautive dans le blame est en fait celui qui a atténué le problème. Le vrai coupable est le commit d’origine qui a livré les trois faiblesses d’un coup — dont l’absence d’index unique dès le départ.
La correction : MAX+1 + advisory lock + transaction enveloppante
L’instinct, c’est de poser un UNIQUE (owner_id, reference) et de basculer le calcul sur MAX(numéro) + 1. On va voir pourquoi l’index unique est impossible ici — mais d’abord le calcul.
Calculer à partir du plus grand numéro, pas du dernier créé
public function getHighestReferenceNumber(Owner $owner, DocumentReferenceType $type): int
{
// Verrou par (propriétaire, type), tenu jusqu'au commit de la transaction
$this->lockSequence($owner, $type);
$references = $this->createQueryBuilder('d')
->select('d.reference')
->where('d.owner = :owner')->setParameter('owner', $owner)
->andWhere('d.reference LIKE :prefix')
->setParameter('prefix', $type->prefix() . '|%')
->getQuery()
->getSingleColumnResult();
// MAX numérique calculé en PHP : volume faible par propriétaire,
// et on évite un cast SQL fragile sur une référence éventuellement malformée
return array_reduce(
$references,
static fn (int $max, string $ref): int => max($max, (int) substr($ref, strpos($ref, '|') + 1)),
0,
);
}
Deux choix volontaires :
- Le
MAXest calculé en PHP, pas en SQL. Le volume par propriétaire est faible (quelques dizaines de lignes), et unMAX(split_part(reference, '|', 2)::int)en SQL explose sur la moindre référence héritée malformée. KISS : on lit la colonne, on extrait la partie numérique, on prend le max. Élimine d’emblée le piège lexicographique (IN|9 > IN|10en tri texte) puisqu’on compare des entiers. - On ne réutilise jamais un numéro existant. Plus de dépendance à l’ordre de création : que
IN|11ait été créé avant ou aprèsIN|10, le max vaut 11, le suivant vaut 12.
Le verrou de concurrence : advisory lock PostgreSQL
MAX+1 corrige le tri, mais pas la course : deux process qui lisent le même MAX au même instant produisent le même +1. La parade, faute d’index unique (voir plus bas), c’est un advisory lock transactionnel, scopé au couple (propriétaire, type) :
private function lockSequence(Owner $owner, DocumentReferenceType $type): void
{
// Une seule séquence verrouillée à la fois : (owner, type).
// pg_advisory_xact_lock se libère automatiquement au COMMIT/ROLLBACK.
$key = $owner->getId()->toRfc4122() . '|' . $type->prefix();
$this->getEntityManager()->getConnection()->executeStatement(
'SELECT pg_advisory_xact_lock(hashtext(:key))',
['key' => $key],
);
}
pg_advisory_xact_lock est un verrou applicatif léger : il ne pose pas de lock de ligne, il sérialise simplement les sections critiques qui partagent la même clé. Deux générations pour des propriétaires différents ne se bloquent pas. Le _xact_ garantit la libération automatique à la fin de la transaction — pas de risque de verrou orphelin si le process meurt.
Le piège : tenir le verrou jusqu’au COMMIT
Un advisory lock transactionnel ne sert à rien si la lecture MAX et la persistance du document ne sont pas dans la même transaction. Sinon le verrou se relâche entre les deux, et la fenêtre de course se rouvre. Il faut enrober lecture + écriture dans une transaction explicite :
trait TransactionalDocumentGenerationTrait
{
private function generateWithinTransaction(callable $generate): Document
{
$connection = $this->entityManager->getConnection();
// Si on est déjà dans une transaction (ex. appel depuis le postPersist
// d'un listener Doctrine), ne pas en imbriquer une seconde — le verrou
// de la transaction parente couvre déjà la section critique.
if ($connection->isTransactionActive()) {
return $generate();
}
return $this->entityManager->wrapInTransaction($generate);
}
}
Le garde isTransactionActive() est essentiel : la génération de document est appelée depuis plusieurs chemins. Certains (le cron, l’action à la demande) n’ont pas de transaction ouverte → on en crée une. D’autres (un postPersist de listener de paiement) tournent déjà dans une transaction Doctrine → imbriquer un wrapInTransaction créerait un savepoint inutile et fragiliserait le rollback. On laisse la transaction parente porter le verrou.
C’est exactement le genre de détail qu’on oublie : on écrit pg_advisory_xact_lock, les tests unitaires passent, et en prod le verrou ne protège rien parce qu’il est relâché trois lignes plus loin, avant le flush. Un verrou transactionnel n’a de valeur que si tu maîtrises où commence et où finit la transaction.
Le vrai twist : on ne peut pas créer l’index unique
Voici où l’histoire devient intéressante. La « bonne » solution, celle qui rend le bug structurellement impossible, c’est le garde-fou en base :
ALTER TABLE document
ADD CONSTRAINT document_reference_owner_unique
UNIQUE (owner_id, reference);
Sauf que cette migration échoue. La base contient déjà des centaines de doublons. PostgreSQL refuse de créer une contrainte d’unicité sur des données qui la violent déjà :
ERROR: could not create unique index "document_reference_owner_unique"
DETAIL: Key (owner_id, reference)=(…, IN|5) is duplicated.
Pour créer l’index, il faudrait d’abord nettoyer l’historique — renuméroter les doublons existants. Et c’est là que la décision produit bascule du technique au métier : « on ne touche pas à l’existant ». Les doublons historiques restent en l’état. On corrige uniquement pour les nouvelles factures.
Conséquence en cascade :
Puisque les doublons existants ne sont pas corrigés, l’index unique ne peut pas être créé. Le correctif devient applicatif uniquement, sans migration de schéma. Et l’advisory lock, qui aurait pu n’être qu’une ceinture en plus de la bretelle « index unique », devient le garde-fou principal — le seul rempart contre la concurrence.
C’est un renversement de priorités qu’il faut assumer dans le code et dans les tests. Le test qui validait l’index unique (databaseRejectsDuplicateReferenceForSameOwner) est abandonné, remplacé par des tests qui prouvent que le verrou applicatif tient :
#[Test]
public function consecutiveGenerationsProduceDistinctNumbers(): void
{
$owner = $this->getCurrentOwner();
$generator = self::getContainer()->get(DocumentReferenceGenerator::class);
$first = $generator->generate($owner, DocumentReferenceType::INVOICE);
$this->persistInvoiceDocument($owner, $first, new DateTimeImmutable('2025-07-31 10:00:00'));
$this->getEntityManager()->flush();
$second = $generator->generate($owner, DocumentReferenceType::INVOICE);
self::assertNotSame($first, $second);
}
Leçon d’architecture : quand tu ne peux pas mettre la contrainte d’intégrité là où elle devrait être (la base), tu dois la reconstituer un cran plus haut (l’application) — en sachant que ce garde-fou est plus faible (il ne protège que les chemins qui passent par ton code, pas un INSERT manuel, pas un autre service). Documenter cette faiblesse n’est pas optionnel : le prochain dev qui voit pg_advisory_xact_lock au lieu d’un UNIQUE doit comprendre que c’est un choix contraint, pas une préférence.
Le volet inattendu : un numéro en double, c’est une dette comptable
On pourrait s’arrêter là : bug compris, fix mergé, nouveaux doublons impossibles. Mais le stock historique pose une question que le code ne peut pas trancher seul : un document avec un numéro en double, est-ce un problème comptable ? Et si oui, a-t-on le droit de le renuméroter en base ?
Le réflexe « je fais un UPDATE pour réparer l’historique » est un piège. Selon la nature du document, c’est soit anodin, soit illégal.
La numérotation des factures est encadrée par la loi
En France, l’article 242 nonies A du CGI impose aux factures soumises à TVA un numéro unique fondé sur une séquence chronologique et continue. Un doublon viole cette unicité et expose à des sanctions réelles (amende au pourcentage du montant facturé, jusqu’au rejet du Fichier des Écritures Comptables en cas de contrôle).
Pire : depuis la loi anti-fraude TVA (article 286 I 3° bis CGI), tout logiciel émettant des factures TVA doit garantir l’inaltérabilité des données. Une renumérotation rétroactive silencieuse en base est strictement interdite. La seule correction conforme d’une facture TVA, c’est la procédure « avoir + ré-émission » :
- émettre un avoir (note de crédit) référençant la facture initiale ;
- émettre une nouvelle facture avec le prochain numéro libre et la mention « Annule et remplace la facture n° X » ;
- conserver les trois documents.
Autrement dit : si ces documents étaient des factures TVA, le UPDATE document SET reference = ... qui semblait évident serait une fraude documentaire.
Sauf que… ce ne sont pas des factures TVA
Le croisement entre le préfixe de référence et le type réel du document (déduit du nom de fichier) change tout :
| Préfixe | Type réel | Régime juridique |
|---|---|---|
IN | Quittance de loyer | Loi n° 89-462 (art. 21) |
IN / PR | Reçu de paiement | Pas d’obligation de numérotation |
IN | Reçu de caution | Pas d’obligation de numérotation |
Aucun document du périmètre n’est une facture commerciale soumise à TVA. Ce sont des quittances de loyer et des reçus, relevant du régime « gestion locative » (location nue, exonérée de TVA). Et l’article 21 de la loi de 1989 qui régit les quittances n’impose aucune numérotation chronologique unique : il impose la gratuité, le détail loyer/charges, la délivrance à la demande. Point.
Le risque fiscal direct s’effondre. Reste un risque résiduel qui se déplace :
- opérationnel : justifier un paiement contesté (charge de la preuve, art. 1353 du Code civil) ;
- image : deux PDF au même numéro envoyés au même locataire → soupçon d’erreur ou de fraude ;
- conservation : les pièces comptables se gardent 10 ans (art. L.123-22 du Code de commerce) — donc on ne supprime jamais un doublon, on peut au mieux le marquer annulé.
Trois stratégies, et celle qu’on retient
Cette qualification juridique débloque une option de remédiation interne qui serait interdite sur des factures TVA :
- Option A — statu quo. On ne touche pas à l’existant, on documente la décision dans un PV interne (pièce de défense en cas de contrôle). Coût nul, risque faible parce que ce ne sont pas des factures TVA.
- Option B — rattrapage « par le livre » (avoir + ré-émission). Conforme à 100 %, mais disproportionné ici : ré-envoyer des centaines de « factures rectificatives » à des locataires crée plus de risque opérationnel et d’image qu’il n’en résout. Réservé aux vraies factures TVA, s’il en existe.
- Option C — rattrapage technique interne, sans réémission. Renuméroter les doublons en base au-dessus du
MAXcourant, en traçant l’ancien numéro dans une colonneprevious_reference, en conservant les PDF originaux sur le stockage objet, et en signant un PV interne. Le locataire ne voit rien. Bénéfice secondaire décisif : une fois les doublons résorbés, l’index unique redevient créable — le garde-fou DB qu’on n’avait pas pu poser.
La renumérotation de l’option C est un script idempotent, sous le même advisory lock que le fix applicatif, exécuté hors des heures de cron :
Pour chaque groupe (owner_id, reference) en doublon :
docs = SELECT * FROM document
WHERE owner_id = :owner AND reference = :ref
ORDER BY created_at ASC, id ASC -- tie-breaker sur id, cette fois
conserve = docs[0] // le plus ancien garde son numéro
a_renumeroter = docs[1..]
next = MAX(numéro du couple owner/type) + 1
Pour chaque doc dans a_renumeroter :
doc.previous_reference = doc.reference
doc.reference = prefix || '|' || next
doc.cancellation_reason = 'Rattrapage doublons historiques'
next = next + 1
Avec une précondition non négociable avant de poser l’index unique :
-- Doit renvoyer ZÉRO ligne, sinon la migration d'index échouera
SELECT owner_id, reference, COUNT(*)
FROM document
WHERE reference IS NOT NULL
GROUP BY owner_id, reference
HAVING COUNT(*) > 1;
Le point que je retiens : la stratégie de remédiation d’un bug de données n’est pas une décision de dev. Ici, c’est la qualification juridique du document — facture TVA vs quittance de loyer — qui détermine si on a le droit de faire un UPDATE, et donc l’architecture du correctif. J’ai écrit la requête d’audit, le script de renumérotation, le test d’idempotence ; mais la ligne qui débloque tout, c’est la validation d’un expert-comptable confirmant qu’aucune pièce n’est une facture assujettie.
Lessons learned
ORDER BY <colonne> DESC LIMIT 1sans tie-breaker sur une colonne non unique = ligne arbitraire. Avec unTIMESTAMP(0)(précision seconde), tous les enregistrements d’une même rafale sont ex æquo : la base te rend une ligne, pas la dernière. Toujours un second critère déterministe (id, séquence) dans leORDER BYd’unLIMIT 1.- « Dernier créé » ≠ « plus grand numéro ». Dès qu’une régénération, une reprise ou un import peut produire un numéro élevé avec un
created_atancien, calculer un compteur à partir de l’ordre de création réintroduit des doublons. Calculer à partir duMAXde la valeur, jamais de la date. - Comparer la valeur numérique, pas la chaîne.
IN|9 > IN|10en tri lexical. Extraire l’entier et comparer des entiers — unMAXen PHP sur un petit volume est plus robuste qu’un::intSQL qui explose au premier enregistrement malformé. - Un advisory lock transactionnel ne protège que ce que couvre sa transaction.
pg_advisory_xact_lockpuisflushhors de la transaction = verrou relâché trop tôt, fenêtre de course intacte. Enrober lecture + écriture dans une seule transaction, et gérer le cas « déjà dans une transaction » avecisTransactionActive()pour ne pas imbriquer. - Tu ne peux pas toujours créer l’index unique a posteriori. Si les données violent déjà la contrainte et qu’on refuse de les nettoyer, la migration échoue. Le garde-fou redescend alors dans l’applicatif — plus faible car il ne couvre que les chemins qui passent par ton code. Ce choix se documente explicitement dans le code et les tests, sinon il ressemble à une négligence.
- Le
git blamement sur les coupables. Le commit qui « touche » la ligne fautive est parfois celui qui a atténué le bug. Lire les diffs, pas seulement la dernière main sur la ligne — le vrai coupable est souvent le commit d’origine qui a livré l’absence de garde-fou. - Un numéro de document en double n’est pas qu’un bug, c’est une question comptable. Selon que la pièce est une facture TVA (numérotation chronologique imposée, inaltérabilité, renumérotation rétroactive interdite) ou une quittance de loyer (aucune obligation de numérotation), la même donnée se corrige par un simple
UPDATEinterne… ou exige une procédure d’avoir formelle. La nature juridique du document dicte l’architecture du correctif. - « Réparer l’historique » est une décision métier, pas technique. Le dev produit l’audit, le script idempotent, les tests, la précondition de zéro-doublon avant l’index. Mais le droit de renuméroter, lui, vient d’une validation comptable. Coder le rattrapage avant cette validation, c’est risquer de transformer un bug d’affichage en faute documentaire.
#php #symfony #doctrine #postgresql #concurrency #advisory-lock #billing #accounting #data-integrity #troubleshooting