Le pattern Strategy de notre moteur de facturation avait une faille simple : sur les deux stratégies, une seule appelait persist(). Dans un SaaS d’abonnements, le cron de génération annonçait chaque nuit « 5 invoice(s) created » alors qu’aucune facture n’arrivait en base, donc aucun PDF ni e-mail ensuite. La stratégie standard renvoyait des factures en mémoire, la stratégie legacy les persistait, et l’appelant faisait confiance aux deux. Une valeur par défaut divergente entre migration et entité envoyait tous les nouveaux abonnements sur la mauvaise.
Un cron qui compte des factures que la base n’a jamais vues
Le moteur a deux implémentations d’une même interface, choisies par abonnement via un champ billingSystem. Le pattern Strategy, c’est exactement ça : une interface, plusieurs algorithmes interchangeables, un registre qui choisit lequel appliquer.
interface BillingEngineInterface
{
public function getName(): string;
/** @return list<Invoice> */
public function generateInvoices(Subscription $subscription): array;
public function supports(Subscription $subscription): bool;
}
La commande app:invoices:generate tourne à 4 h et compte ce que renvoie la stratégie. En local, sur cinq abonnements :
Invoice generations: 5 subscription(s) to process
Invoice generation: "0" invoice(s) created for subscription "<id>" using engine "legacy"
...
Invoice generation: "4" invoice(s) created for subscription "<id>" using engine "standard"
Invoice generation: "1" invoice(s) created for subscription "<id>" using engine "standard"
[OK] End invoice generations: 5 subscription(s) processed, 5 invoice(s) created
Une requête sur la table des factures, juste après, donnait le vrai bilan :
billing_system | subscriptions | invoices
----------------+---------------+----------
legacy | 3 | 9
standard | 2 | 0
Un compteur de log calculé sur un tableau en mémoire ne prouve rien : il compte des objets, pas des lignes.
La fausse piste : une date d’effet de révision tarifaire
J’ai d’abord cherché ailleurs. Un abonnement de test échouait avec No price revision with price before the begin term … : la première révision tarifaire portait la date de création de l’abonnement au lieu de sa date de début. Le correctif front était réel et utile. Mais ce message n’apparaissait pas dans les logs de prod : aucun abonnement n’y était en exception, et pourtant aucune facture n’était créée. Mon abonnement de test était un legacy, créé avant le changement de défaut. Il échouait bruyamment ; les nouveaux échouaient en silence.
Pattern Strategy : deux stratégies, deux contrats implicites
| Stratégie | Ce que fait generateInvoices() |
|---|---|
legacy | délègue à InvoiceService::createFromSubscription($subscription, flush: true) : persist() + flush() |
standard | InvoiceGenerator::generateForSubscription() crée un new Invoice() et le renvoie, sans persist() |
public function generateInvoices(Subscription $subscription): array
{
$schedule = $this->scheduleCalculator->calculate($subscription);
$invoices = [];
foreach ($schedule->getPendingItems() as $item) {
$invoices[] = $this->invoiceGenerator->generateForSubscription(
$subscription,
$item->getBillingPeriod(),
);
}
return $invoices; // objets en mémoire, jamais vus par Doctrine
}
Le piège bonus : une commande alternative, billing:generate-invoices, persistait elle-même chaque facture renvoyée. Avec elle, la stratégie standard fonctionnait. Seulement, ce n’était pas elle qui était dans la crontab.
Pourquoi tous les nouveaux abonnements prenaient la stratégie qui ne persistait pas ?
La colonne billing_system a été ajoutée par la migration Version20251106112406 avec DEFAULT 'legacy'. En février 2026, l’activation du nouveau moteur a changé l’entité : options: ['default' => 'standard'] et une propriété initialisée à BillingSystem::STANDARD. Les abonnements existants sont restés legacy ; tout abonnement créé par l’API depuis est parti en standard, car le formulaire n’envoie jamais ce champ. Personne n’a choisi le moteur qui ne persiste pas : c’est la valeur par défaut qui l’a choisi.
Le correctif : persister dans la stratégie, et relire la base dans le test
J’ai mis la persistance dans la stratégie, par symétrie avec legacy. InvoiceGenerator reste pur, réutilisable pour un aperçu ou un dry-run.
foreach ($pendingItems as $item) {
$invoice = $this->invoiceGenerator->generateForSubscription($subscription, $item->getBillingPeriod());
$this->entityManager->persist($invoice);
$invoices[] = $invoice;
}
if ($invoices !== []) {
$this->entityManager->flush();
}
Le test unitaire vérifie un persist() par facture et un seul flush(). Mais celui qui compte est le test d’intégration : il vide l’EntityManager, puis relit la base.
$invoices = $this->standardEngine->generateInvoices($subscription);
$this->getEntityManager()->clear();
$persisted = (int) $this->getRepository(Invoice::class)->createQueryBuilder('i')
->select('COUNT(i.id)')
->where('i.subscription = :subscription')
->setParameter('subscription', $subscriptionId, 'uuid')
->getQuery()->getSingleScalarResult();
self::assertSame(count($invoices), $persisted);
C’est la même famille de piège que le filtre Prisma not qui exclut les NULL : un test qui ne regarde pas l’état réel des données passe au vert sur le bug.
Le correctif a eu un effet de bord immédiat : le cron d’envoi du matin a enfin reçu des factures à traiter, et le premier abonnement incohérent l’a fait tomber.
Lessons learned
- Toutes les stratégies d’une interface doivent partager le même contrat de sortie. « Renvoie des factures » ne suffit pas : persistées ou non, c’est écrit dans l’interface et vérifié pour chaque implémentation.
- Un log qui compte un tableau en mémoire n’est pas une preuve d’écriture. Le seul compteur fiable est celui qu’on relit en base après
clear(). - Une valeur par défaut existe en double : migration et entité. Quand elles divergent, anciennes et nouvelles lignes prennent des chemins différents, sans erreur.
- Une fausse piste bruyante masque souvent la cause silencieuse. Vérifier que le message d’erreur suivi apparaît vraiment dans les logs de prod avant de s’y attacher.
- Le chemin qui compte est celui de la crontab. Une commande correcte qui ne tourne jamais ne protège rien.