Aller au contenu
Sylvain Joffraud l4w
Retour

Pattern Strategy : un moteur de facturation sans `persist()`

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égieCe que fait generateInvoices()
legacydélègue à InvoiceService::createFromSubscription($subscription, flush: true) : persist() + flush()
standardInvoiceGenerator::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

Questions fréquentes

Pourquoi Doctrine n’enregistre pas une entité créée avec <code>new</code> ?
Doctrine n’enregistre une entité créée avec new que si l’EntityManager la connaît. Il faut appeler persist() pour qu’elle soit suivie, puis flush() pour écrire en base. Sans persist(), l’objet vit en mémoire jusqu’à la fin du process et disparaît sans erreur ni avertissement, même s’il est complet et valide.
Qui doit persister dans un pattern Strategy : la stratégie ou l’appelant ?
Dans un pattern Strategy, le choix importe moins que la cohérence : toutes les stratégies d’une même interface doivent avoir le même contrat de sortie. Si une stratégie renvoie des entités déjà persistées et une autre des objets en mémoire, l’appelant ne peut pas être correct pour les deux. Le contrat se documente dans l’interface et se vérifie par un test commun.
Comment tester qu’une commande Symfony écrit vraiment en base ?
Un test d’intégration qui écrit vraiment en base exécute la commande ou le service, appelle $entityManager->clear() pour vider la mémoire de Doctrine, puis relit la table avec une requête COUNT. Comparer ce nombre à celui renvoyé par le service prouve la persistance. Un mock d’EntityManager prouve seulement que persist() a été appelé.
Pourquoi une valeur par défaut différente entre migration et entité pose problème ?
Une valeur par défaut de migration ne s’applique qu’aux lignes existantes et aux INSERT qui omettent la colonne. Doctrine, lui, envoie toujours la valeur initialisée dans l’entité PHP. Si la migration dit legacy et l’entité STANDARD, les anciennes lignes et les nouvelles prennent deux chemins différents, sans que rien ne le signale.

Partager cet article :

Article précédent
Postgres `permission denied for table` : GRANT, pas RLS
Article suivant
Svelte 5 `$state` et IndexedDB : `DataCloneError`