AkiraAkira.dev
5 min de lecture

Ce que Laravel SISP 3.0 a cassé, et pourquoi

Aucune nouvelle installation ne terminait ses migrations sur MySQL, et personne ne l'a vu pendant toute la 2.x. Voilà où est passée la 3.0.

aussi en EN PT

Pendant toute la vie de la 2.x, aucune nouvelle installation du package ne terminait ses migrations sur MySQL ni sur MariaDB. La toute première migration créait un index sur quatre colonnes à la fois :

// database/migrations/create_laravel_sisp_table.php
$table->index(['merchant_ref', 'merchant_session', 'status', 'message_type']);

Quatre colonnes varchar(255) en utf8mb4 réclament 4080 octets de clé. InnoDB en autorise 3072.

Laravel SISP 3.0 modifie 141 fichiers répartis sur 128 commits, dont sept portent un !. Six sont des corrections, le septième durcit une valeur par défaut, et aucun n’élargit la surface du package.

L’intégration continue ne testait pas le moteur de production

Personne n’a vu la limite d’InnoDB parce que le pipeline ne tournait que sur SQLite, qui n’impose aucune limite de longueur de clé. Un package de paiement pour le réseau national, et la suite passait au vert en mesurant un moteur que personne n’installe.

L’index ne porte plus que sur merchant_session, et un nouveau job exécute les migrations sur MySQL 8 et PostgreSQL 16 à chaque push. Dès sa première exécution, il a trouvé un second défaut : migrate:rollback mourait sur MySQL avec l’erreur 1553. foreignId()->constrained() amène MySQL à créer son propre index sur transaction_id, le garde-fou voyait cet index et passait outre la création de l’index dédié, et la clé étrangère se retrouvait suspendue à l’index unique que le rollback supprimait.

Deux défauts du même aveuglement, dont le second n’a demandé qu’une exécution pour être trouvé.

Un remboursement qui ne partait jamais

RefundTransactionAction construisait une RefundRequest signée, la stockait, passait la transaction en refunded et émettait TransactionRefunded. La facture, elle, ne bougeait pas : elle ne suit le remboursement que depuis la 3.0. Sans une seule requête sortante. Il ne pouvait pas : le seul client HTTP du package est TransactionStatusClient, qui interroge SISP sur le statut d’une transaction et rien d’autre.

Un listener sur cet événement avait toutes les raisons d’annoncer au client que l’argent arrivait.

La 3.0 nomme l’opération d’après ce qu’elle fait : RecordRefundAction, l’événement RefundRecorded, RefundBuilder::record(), et la route répond Refund recorded. Issue it in the SISP back office if you have not already. Un test interdit à l’opération de repartir :

// tests/Actions/RecordRefundReachesNoGatewayTest.php
it('sends nothing to the gateway when a refund is recorded', function (): void {
    Http::preventStrayRequests();
    Http::fake();

    resolve(RecordRefundAction::class)->handle(recordableTransaction(), 100.0, 'customer_request');

    Http::assertNothingSent();
});

Chez SISP, un remboursement est émis depuis le back office et se confirme dans le fichier de rapprochement quotidien. Le package tient la comptabilité. Le dire dans le nom de la classe coûte une rupture et supprime toute une catégorie de tickets.

Celui qui frappe à la porte n’est pas SISP

Trois des sept ruptures font moins confiance à la requête entrante.

Les générateurs par défaut ajoutent dix caractères aléatoires en majuscules à l’horodatage :

// src/Actions/Generators/MerchantReferenceGeneratorAction.php
public function __invoke(): string
{
    return 'R'.now()->format('YmdHis').Str::upper(Str::random(10));
}

R20260523235959 devient R20260523235959K7M2QX9TBV. De quinze caractères à vingt-cinq, et deux paiements lancés dans la même seconde cessent d’entrer en collision.

Le callback valide l’empreinte avant le pipeline, et un callback rejeté n’écrit plus rien :

// src/Http/Controllers/CallbackController.php
if ($this->rejectsFingerprint($payload)) {
    Log::warning('SISP callback rejected: the fingerprint does not match.', [
        'merchant_ref' => $payload->merchantRef,
    ]);

    return redirect(config('sisp.redirect_url', '/'));
}

Jusqu’au correctif 2.2, quiconque connaissait la référence et la session d’un paiement en attente le faisait passer en failed, annulait sa facture, déclenchait PaymentFailed et effaçait les champs de refus écrits par SISP. Le correctif est sorti dans ce patch et la 3.0 le porte comme rupture déclarée : la transaction, sa tentative, sa facture et les métadonnées de la requête restent intactes, et aucun événement ne part. Si vous cherchiez merchant_response = 'invalid_callback_fingerprint' pour repérer un callback forgé, surveillez plutôt cette ligne de log.

La page de résultat ferme la troisième porte : elle ne s’affiche que derrière une URL signée valable trente minutes, et sa prop Inertia ne transporte plus merchant_session. La référence et la session réunies suffisent toujours à annuler un paiement en attente via le callback d’annulation non signé, et la page livrait l’une des deux. C’est l’imprévisibilité de la référence qui protège, donc une installation restée sur les générateurs 2.x garde la faille.

Un statut vit dans une ligne, alors verrouillez-la

L’annulation refusait completed et cancelled, ce qui laissait les transactions en failed et en refunded libres de basculer vers cancelled. Elle refuse maintenant les quatre. Le verrou, lui, est plus ancien : dès la 2.2, la transaction était relue avec lockForUpdate() dans une transaction de base de données et le statut vérifié sur la ligne verrouillée, pour que deux annulations simultanées ne gagnent pas toutes les deux. Ce que la 3.0 ajoute, ce sont les deux statuts refusés supplémentaires et la facture déplacée dans la même transaction.

InvoiceStatus gagne un cas refunded : un remboursement total y conduit la facture, un remboursement partiel ne touche ni la transaction ni la facture. Un match exhaustif sur cet enum sans branche default lève UnhandledMatchError dès la première facture remboursée qui l’atteint, et c’est exactement la forme qu’une rupture doit avoir.

Sept ruptures, aucun alias

L’objection est légitime : sept ruptures dans une seule version, c’est un coût reporté sur tout le monde, et un class_alias avec un cycle de dépréciation aurait été moins brutal pour les intégrations.

Pour les renommages, cela aurait été pire. Du code qui appelle event(new TransactionRefunded(...)) plante sur une classe qui n’existe plus, et un plantage se corrige en quinze minutes. Un listener laissé sur l’ancien nom aliasé continue de compiler et cesse de se déclencher, sans erreur et sans log, sur un chemin de remboursement dont quelqu’un dépend. Le guide de migration demande trois choses dans cet ordre : renommer le code qui émet l’événement, vider la file d’attente avant d’installer, corriger les doublures de test.

Une version sans nouveauté peut être celle qui vous évite d’annoncer un virement qui n’a jamais existé.

Rechercher dans le Dev Log

Rechercher articles, étiquettes, idées…