Mettre en place une infrastructure de facturation fiable avec FlexPay et Symfony

Comment construire un workflow de paiement durable autour de FlexPay avec des commandes, des transactions de paiement, des webhooks signés, une livraison idempotente et une migration depuis un ancien modèle de facturation.
Avez-vous déjà intégré un prestataire de paiement, reçu une réponse positive et supposé que le travail de facturation était terminé ?
Appeler une API de paiement est la partie la plus simple.
La partie difficile commence lorsque :
-
le prestataire accepte la demande, mais son webhook arrive plusieurs minutes plus tard ;
-
le client ferme le navigateur avant de revenir à l'application ;
-
le même webhook est livré deux fois ;
-
deux rappels arrivent simultanément ;
-
le prestataire devient indisponible après le démarrage du checkout ;
-
le paiement réussit, mais l’attribution de l’accès échoue ;
-
ou le support doit comprendre ce qui s'est passé trois mois plus tard.
Un prestataire de paiement répond à une question précise : cette tentative de paiement peut-elle être traitée ?
Un système de facturation doit répondre à un ensemble de questions beaucoup plus large :
-
Qu'est-ce que le client avait l'intention d'acheter ?
-
Quel prix ai-je facturé ?
-
Quelle tentative de paiement appartient à cet achat ?
-
Quelles preuves le prestataire a-t-il envoyées ?
-
Le rappel a-t-il déjà été traité ?
-
La commande a-t-elle été payée ?
-
L'accès acheté a-t-il été réellement livré ?
-
L'historique complet peut-il être audité et réessayé en toute sécurité ?
Lors de la reconstruction de l'infrastructure de facturation d'une plateforme Symfony, j'ai intégré FlexPay pour Mobile Money et les paiements par carte. Je maintiens également le client PHP utilisé par cette intégration, ce qui m'a donné une visibilité directe sur le protocole du prestataire, le mapping des requêtes et des réponses, ainsi que sur le comportement du SDK à la frontière de l'infrastructure. Je devais aussi composer avec plusieurs années de comportement et de données dans une application existante : il ne s'agissait donc pas d'un projet vierge.
L'ancien système contenait déjà de bonnes idées : une interface de passerelle de paiement, des URL de rappel signées, des transactions en attente et une passerelle en mémoire. Le refactor a conservé ces points de séparation utiles tout en distinguant l'intention commerciale, les tentatives de paiement, les événements du prestataire et l’attribution de l’accès.
En tant que cofondateur, cette distinction dépasse pour moi la qualité du code. Un incident de paiement devient immédiatement un problème de confiance. Si un client paie sans recevoir son accès, l'architecture devient à la fois un problème de support, un problème financier et un problème de réputation.
Dans cet article, je vais montrer comment le flux de facturation est structuré, comment FlexPay est isolé derrière un adaptateur, ce que j'ai appris de l'implémentation existante et comment vous pouvez reproduire la même architecture dans un autre projet Symfony.
Une intégration de paiement n'est pas un modèle de facturation
La première version de nombreuses intégrations de paiement ressemble à ceci :
Ce code regroupe plusieurs faits différents en une seule condition.
Une réponse d'initiation acceptée peut simplement signifier que le prestataire a pris la demande en charge. Cela ne prouve pas que l'argent a été encaissé. Une redirection du navigateur ne constitue pas une preuve de paiement. Un abonnement n'est pas une transaction de paiement. Un rappel du prestataire n’est pas une commande.
Je modélise maintenant ces concepts séparément :
Ce vocabulaire constitue le fondement de l'implémentation.
L'architecture en un seul flux
La décision de conception importante est que FlexPay n'active jamais directement un abonnement. Il ne modifie qu’un fait de paiement. Les événements applicatifs coordonnent ensuite les conséquences d’un paiement réussi pour le reste du système.
Ce que l'ancien système faisait déjà bien
Il est tentant de décrire un système existant uniquement à travers ses problèmes. Cela cache généralement les décisions qui ont rendu le refactor possible.
Mon implémentation précédente avait déjà une interface OnlinePaymentGateway :
interface OnlinePaymentGateway
{
public function createMobileRequest(...): MobileRequest;
public function createCardRequest(...): CardRequest;
public function pay(
MobileRequest|CardRequest $request,
): PaymentResponse|CardResponse;
public function handleCallback(array $data): PaymentResponse;
}Il y avait aussi :
-
un véritable adaptateur FlexPay ;
-
un adaptateur en mémoire pour le développement ;
-
des URL de rappel signées ;
-
un
Transactionen attente créé avant d'appeler le prestataire ; -
et des routes de rappel distinctes pour les résultats acceptés, refusés et annulés.
C'étaient des fondations précieuses.
Les limitations sont apparues au fur et à mesure de la croissance de l'application.
L'abstraction de la passerelle a importé les classes de requête et de réponse FlexPay directement dans le contrat de domaine. Un commentaire dans l'ancienne interface documentait même le problème : la passerelle devait encore être découplée de FlexPay.
L'ancien checkout créait un Subscription en attente ou une entité d'accès à vie avant le paiement. Un rappel refusé supprimait ensuite cet objet du domaine. En d’autres termes, un concept d’accès servait aussi d’état temporaire au checkout.
Le contrôleur de rappel analysait les données du prestataire, vérifiait l'état de la transaction, choisissait une commande, gérait les exceptions, rendait les pages du navigateur et journalisait la charge utile du prestataire. Trop de responsabilités se rencontraient à la frontière la plus sensible de l'application.
Il n'existait pas non plus de journal durable des événements du prestataire, d'empreinte de la charge utile ni de frontière explicite de verrouillage pessimiste. La principale protection contre un webhook répété consistait à vérifier si la transaction était encore en attente.
Ces problèmes ne signifiaient pas que l'ancien système était inutile. Ils m’ont montré où placer les prochaines frontières.
Étape 1 : Séparer l'état de la commande de l'état du paiement
Un Order représente une intention commerciale :
enum OrderStatus: string
{
case Draft = 'draft';
case PendingPayment = 'pending_payment';
case Paid = 'paid';
case Fulfilled = 'fulfilled';
case Cancelled = 'cancelled';
case Failed = 'failed';
}Un PaymentTransaction représente une tentative de collecte d'argent :
enum PaymentStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Succeeded = 'succeeded';
case Failed = 'failed';
case Cancelled = 'cancelled';
case Refunded = 'refunded';
}Ces machines d'état sont liées, mais elles ne sont pas interchangeables.
Une commande peut faire l'objet de plusieurs tentatives de paiement. La réussite d'un paiement ne prouve pas que l'accès a été attribué. Une commande exécutée indique que la capacité achetée a bien été livrée.
Séparer les machines à états donne aux équipes opérationnelles et au support une réponse bien plus précise qu'une seule colonne payment_status attachée à un abonnement.
Étape 2 : Capturer un instantané de l'achat
Un article de commande stocke le produit, le prix sélectionné, le montant facturé et les métadonnées d’attribution :
final readonly class OrderItem
{
public function __construct(
public OrderItemId $id,
public OrderId $orderId,
public ProductId $productId,
public PriceId $priceId,
public Money $amount,
public OrderItemMetadata $metadata,
) {}
}Ceci est important car les données du catalogue changent.
Si un prix change le mois prochain, une facture existante ne doit pas changer silencieusement avec lui. L'article de commande conserve l'identité du prix et le montant réel facturé à partir du moment du paiement.
Mon checkout résout un produit et un prix actifs, applique les éventuelles règles tarifaires propres au pays, puis stocke la valeur Money obtenue sur la commande et son article.
final readonly class Money
{
public function __construct(
public int $amount,
public Currency $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException(
'A monetary amount cannot be negative.',
);
}
}
}Ne transmettez jamais un montant nu dans le code de facturation. 10000 sans CDF ou USD constitue une donnée métier incomplète.
Étape 3 : Persister l'intention du checkout avant d'appeler FlexPay
Les requêtes HTTP externes ne doivent pas être exécutées lorsqu'une transaction de base de données détient des verrous.
Mon checkout en libre-service comporte trois phases :
final readonly class SelfServiceCheckout
{
public function start(CheckoutIntent $intent): CheckoutResult
{
$checkout = $this->transactions->run(
fn () => $this->intentRecorder->record(
$intent,
$this->clock->now(),
),
);
$response = $this->paymentInitiator->initiate(
$intent,
$checkout,
);
$payment = $this->transactions->run(
fn () => $this->paymentRecorder->record(
$checkout->paymentTransaction->id,
$response,
$this->clock->now(),
),
);
return CheckoutResult::from($checkout, $payment, $response);
}
}La première phase enregistre une commande dans pending_payment, un article de commande qui capture un instantané immuable de l'achat et une transaction de paiement dans pending.
La transaction est validée avant que FlexPay ne soit contacté.
La phase deux effectue l'appel externe sans aucune transaction de base de données ouverte.
La phase trois verrouille ou recharge la transaction de paiement et enregistre la réponse d'initiation : l'acceptation du prestataire la fait passer à processing, tandis que son refus la fait passer à failed.
Cette conception me donne un identifiant interne durable avant tout appel réseau. L'identifiant est intégré dans l'URL de rappel et permet de relier chaque interaction ultérieure du prestataire à un enregistrement interne.
Cette conception gère aussi correctement un échec inconfortable, mais important : si FlexPay est indisponible, la commande et l'intention de paiement restent disponibles pour le support et la reprise. Je ne perds pas la trace du checkout et je ne maintiens pas de transaction ouverte pendant l'attente réseau.
Le test unitaire protège précisément cette frontière :
self::assertTrue($gateway->calledOutsideTransaction);
self::assertCount(1, $orders->savedOrders);
self::assertCount(1, $paymentTransactions->savedTransactions);
self::assertSame(
PaymentStatus::Pending,
$paymentTransactions->savedTransactions[0]->status,
);Étape 4 : Placer FlexPay derrière un port sortant
Le code applicatif a besoin d'un petit contrat de passerelle. Si je créais cette frontière aujourd'hui, chaque entrée et chaque sortie appartiendrait à l'application :
interface OnlinePaymentGateway
{
public function createMobileRequest(
Money $amount,
string $reference,
PaymentTransactionId $transactionId,
PhoneNumber $phoneNumber,
?string $description = null,
): object;
public function createCardRequest(
Money $amount,
string $reference,
PaymentTransactionId $transactionId,
string $description,
): object;
public function pay(object $request): PaymentInitiationResult;
public function verifyCallback(array $payload): VerifiedPaymentCallback;
}Mon implémentation actuelle a introduit le port de manière incrémentielle, donc la création de requêtes est derrière le contrat, mais quelques types de requêtes et de réponses du SDK apparaissent toujours dans ses signatures de méthode. Le véritable adaptateur convertit les valeurs de domaine en objets du SDK FlexPay :
final readonly class FlexpayGateway implements OnlinePaymentGateway
{
public function createMobileRequest(
Money $amount,
string $reference,
PaymentTransactionId $transactionId,
PhoneNumber $phoneNumber,
?string $description = null,
): MobileRequest {
return new MobileRequest(
amount: $amount->amount,
reference: $reference,
currency: FlexpayCurrency::from(
$amount->currency->value,
),
phone: ltrim((string) $phoneNumber, '+'),
callbackUrl: $this->urls->accepted($transactionId),
approveUrl: $this->urls->accepted($transactionId),
cancelUrl: $this->urls->cancelled($transactionId),
declineUrl: $this->urls->declined($transactionId),
description: $description,
);
}
}L'adaptateur possède :
-
les identifiants du prestataire et le choix de l'environnement ;
-
la construction des requêtes du SDK ;
-
conversion de devises ;
-
normalisation du numéro de téléphone ;
-
l'analyse des rappels du prestataire ;
-
journalisation d'intégration sécurisée ;
-
et traduction du SDK ou des exceptions réseau en
PaymentGatewayUnavailable.
Je maintiens le package PHP devscast/flexpay utilisé par cet adaptateur. C'est utile sur le plan opérationnel : lorsque le contrat du prestataire évolue ou qu'un cas limite apparaît, je peux examiner à la fois l'intégration applicative et la bibliothèque cliente. Cette maintenance n’intègre cependant pas le package au domaine. Le SDK reste une dépendance d'infrastructure placée derrière la passerelle afin que le domaine Billing n'hérite pas de son modèle de transport.
Cette fuite de types reste un compromis pragmatique. Elle est déjà mieux isolée qu’un usage direct du SDK dans toute l’application, mais elle ne garantit pas encore une indépendance complète vis-à-vis du prestataire.
Pour une nouvelle implémentation, définissez les objets PaymentInitiationResult et VerifiedPaymentCallback appartenant à l'application comme indiqué ci-dessus. L'adaptateur FlexPay doit être le seul endroit qui connaît PaymentResponse, CardResponse ou les codes d'état du prestataire.
L'architecture peut évoluer par étapes. L’important est de savoir où subsiste le couplage.
Étape 5 : Sélectionner la passerelle par configuration
L'application peut utiliser FlexPay en production et une implémentation contrôlée localement :
parameters:
billing.online_payment_gateway.default: 'in_memory'
services:
billing.gateway.in_memory:
alias: App\Billing\Infrastructure\InMemoryFlexpayGateway
billing.gateway.flexpay:
alias: App\Billing\Infrastructure\FlexpayGateway
App\Billing\Domain\Service\OnlinePaymentGateway:
alias: App\Billing\Infrastructure\ConfigurableOnlinePaymentGatewayLa passerelle configurable délègue via un localisateur de services :
private function gateway(): OnlinePaymentGateway
{
$key = match ($this->selectedGateway) {
'in_memory' => 'billing.gateway.in_memory',
'flexpay' => 'billing.gateway.flexpay',
default => throw new LogicException(
'Unknown payment gateway.',
),
};
return $this->gateways->get($key);
}La configuration provient des variables d'environnement :
APP_PAYMENT_GATEWAY=flexpay
FLEXPAY_TOKEN=***
FLEXPAY_MERCHANT=***
FLEXPAY_ENV=prodNe journalisez pas ces valeurs, ne les stockez pas dans des fichiers d'environnement versionnés et ne les incluez pas dans le contexte d'une exception.
Utiliser par défaut un adaptateur en mémoire en développement constitue une mesure de sécurité. L'exécution locale de l'application ne doit pas déclencher accidentellement un paiement réel.
Étape 6 : Générer des URL de rappel signées
Chaque transaction de paiement reçoit des URL de rappel spécifiques au résultat :
final readonly class PaymentWebhookUrlGenerator
{
public function accepted(
PaymentTransactionId $transactionId,
): string {
return $this->generate(
'billing.payment.accepted',
$transactionId,
);
}
private function generate(
string $route,
PaymentTransactionId $transactionId,
): string {
$url = $this->router->generate($route, [
'id' => $transactionId->toString(),
], UrlGeneratorInterface::ABSOLUTE_URL);
return $this->uriSigner->sign($url);
}
}La signature d'URI de Symfony protège la route et l'identifiant de transaction contre la falsification.
Cependant, une URL signée et un rappel vérifié du prestataire prouvent des choses différentes :
| Mécanisme | Ce que cela prouve |
|---|---|
| URL de rappel signée | L'URL a été générée par l'application |
| Vérification du prestataire | La charge utile représente un résultat confirmé par le prestataire |
Un retour du navigateur ne doit jamais être considéré comme une preuve de paiement faisant autorité. Le navigateur peut afficher « en cours », « annulé » ou un statut provisoire, mais seul un rappel vérifié de serveur à serveur — ou une consultation explicite du statut auprès du prestataire — doit faire passer un paiement à succeeded.
Ma frontière de compatibilité actuelle accepte GET et POST sur les routes de résultat, car FlexPay utilise des URL liées pour les retours du navigateur et les rappels du prestataire. Un indicateur verifyProvider distingue le rappel POST de la redirection du navigateur avant leur entrée dans le processeur applicatif. Je renforcerais encore cette séparation : un webhook POST peut modifier l'état du paiement, tandis qu'une page de retour GET distincte se limite à lire et afficher l'état faisant autorité.
Il s'agit d'une règle de durcissement importante lors de la reproduction de l'implémentation. La corrélation n'est pas une authentification, et l'authentification n'est pas une vérification du règlement.
Étape 7 : Normaliser les résultats du prestataire à la frontière
Les codes de résultat FlexPay ne doivent pas se propager dans le domaine.
La frontière de rappel traduit le comportement du prestataire en résultats compris par Billing :
enum PaymentWebhookOutcome: string
{
case Accepted = 'accepted';
case Declined = 'declined';
case Cancelled = 'cancelled';
}Un contrôleur léger rassemble des données de transport non fiables et distribue une commande :
#[Route(
'/billing/system/payment/{id}/accepted',
methods: ['POST'],
)]
#[IsSignatureValid]
final class AcceptedPaymentWebhookController
{
public function __invoke(
PaymentTransactionId $id,
Request $request,
): JsonResponse {
$providerResult = $this->gateway->verifyCallback(
$request->getPayload()->all(),
);
$this->commands->handle(new HandlePaymentWebhook(
paymentTransactionId: $id,
outcome: $providerResult->outcome,
payload: $providerResult->payload,
));
return new JsonResponse([], Response::HTTP_OK);
}
}Le contrôleur n'active pas l'accès, ne modifie pas les entités et ne contient pas la machine à états de paiement.
Pour les paiements acceptés, vérifiez plus que le code de résultat. Comparez la référence interne du rappel, la référence du prestataire, le montant et la devise avec la transaction stockée chaque fois que le prestataire les fournit. Une requête correctement signée contenant des données commerciales incompatibles ne doit pas marquer la commande comme payée.
Étape 8 : Traiter les webhooks comme une livraison au moins une fois
Les prestataires de paiement réessaient les rappels. Le handler doit supposer qu'un même événement peut arriver plusieurs fois et que deux workers peuvent le traiter simultanément.
Mon gestionnaire de webhook utilise deux protections.
Tout d'abord, il verrouille la transaction de paiement :
public function getForUpdate(
PaymentTransactionId $id,
): PaymentTransaction {
return $this->entityManager->find(
PaymentTransaction::class,
$id,
LockMode::PESSIMISTIC_WRITE,
) ?? throw PaymentTransactionNotFound::withId($id);
}Deuxièmement, il enregistre une empreinte digitale :
private function fingerprint(
PaymentWebhookOutcome $outcome,
PaymentProvider $provider,
PaymentTransactionId $transactionId,
array $payload,
): string {
$payload = $this->sortRecursively($payload);
return hash('sha256', json_encode([
'provider' => $provider->value,
'transactionId' => $transactionId->toString(),
'outcome' => $outcome->value,
'payload' => $payload,
], JSON_THROW_ON_ERROR));
}La base de données impose l'unicité sur la paire (provider, fingerprint).
Le flux du gestionnaire devient :
$transaction = $payments->getForUpdate($command->transactionId);
$fingerprint = $this->fingerprint(...);
if ($events->exists($transaction->provider, $fingerprint)) {
return;
}
$event = PaymentEvent::record(
paymentTransactionId: $transaction->id,
provider: $transaction->provider,
fingerprint: $fingerprint,
payload: $command->payload,
now: $clock->now(),
);
if ($transaction->isPendingOrProcessing()) {
$this->apply($command->outcome, $transaction);
}
$event->markProcessed($clock->now());Le verrou protège les transitions d'état concurrentes. L'empreinte protège contre la relecture. La contrainte d'unicité en base de données reste la dernière défense si deux processus franchissent simultanément la vérification d'existence applicative.
Étape 9 : Conserver les preuves brutes séparément de l'état normalisé
Un PaymentTransaction contient le cycle de vie normalisé utilisé par les règles métier.
Un PaymentEvent stocke la preuve du rappel :
final class PaymentEvent
{
public function __construct(
public readonly PaymentEventId $id,
public readonly PaymentTransactionId $transactionId,
public readonly PaymentProvider $provider,
public readonly string $fingerprint,
public readonly DateTimeImmutable $receivedAt,
public ?DateTimeImmutable $processedAt,
public readonly array $payload,
) {}
}Les champs connus du prestataire sont aussi projetés dans des métadonnées de transaction typées :
final readonly class PaymentTransactionMetadata
{
public function __construct(
public ?string $providerReference = null,
public ?string $orderNumber = null,
public ?string $channel = null,
public ?string $failureReason = null,
public array $additionalPayload = [],
) {}
}Les décisions commerciales utilisent un état typé :
$transaction->status === PaymentStatus::Succeeded;Elles n'interprètent pas à répétition des clés arbitraires dans la charge utile du prestataire.
Conserver l'événement brut reste utile pour les audits, les litiges avec le prestataire et le débogage. Cela crée aussi une responsabilité de gouvernance des données. Expurgez les secrets, évitez de stocker des identifiants ou des données de carte inutiles, limitez l'accès administratif et définissez une politique de conservation.
Étape 10 : Rendre les transitions d'état idempotentes
L'agrégat de paiement contrôle les transitions autorisées :
public function markSucceeded(
DateTimeImmutable $now,
array $payload = [],
): void {
if ($this->status === PaymentStatus::Succeeded) {
return;
}
if (! $this->isPendingOrProcessing()) {
throw PaymentStatusTransitionNotAllowed::from(
$this->status,
PaymentStatus::Succeeded,
);
}
$this->status = PaymentStatus::Succeeded;
$this->succeededAt = $now;
$this->metadata = $this->metadata
->withProviderPayload($payload);
$this->recordThat(new PaymentSucceeded(
$this->id,
$this->orderId,
$now,
));
}Un rappel accepté marque également la commande payée :
$payment->markSucceeded($now, $payload);
$order->markPaid($now);L'événement de domaine n'est publié que lorsque la transition se produit réellement. Un rappel dupliqué ne peut donc pas annoncer deux fois la réussite du paiement.
Étape 11 : Attribuer l'accès après le paiement
PaymentSucceeded est le pont entre la facturation et l'accès :
final readonly class PaymentSucceededHandler
{
public function __invoke(PaymentSucceeded $event): void
{
$this->commands->handle(new FulfillPaidOrder(
orderId: $event->orderId,
occurredAt: $event->occurredAt,
));
}
}Le service d’attribution :
-
vérifie si la commande est déjà exécutée ;
-
verrouille la commande ;
-
exige que la commande soit payée ;
-
charge chaque article de commande ;
-
délègue selon le type de produit ;
-
crée ou étend le droit ;
-
et ne marque la commande comme exécutée qu'après la réussite de tous les articles.
if ($order->status === OrderStatus::Fulfilled) {
return;
}
if ($order->status !== OrderStatus::Paid) {
throw new PaidOrderRequired();
}
foreach ($items as $item) {
$this->fulfillItem($order, $item, $now);
}
$order->markFulfilled($now);Chaque parcours d’attribution possède sa propre clé d'idempotence. Un abonnement vérifie s'il en existe déjà un pour la commande. Un octroi d'accès à une œuvre vérifie à la fois la commande et l'œuvre achetée.
C'est plus robuste que de compter uniquement sur le handler de paiement. La livraison de l'événement peut être réessayée sans accorder deux fois le même accès.
La même frontière d’attribution prend en charge les paiements en espèces et par virement bancaire enregistrés manuellement. Le checkout administratif crée un PaymentTransaction avec un prestataire manuel, le marque comme réussi et suit les mêmes règles d'audit et d’attribution des commandes payées sans prétendre être passé par FlexPay.
Étape 12 : Renvoyer un résultat de checkout exploitable par le frontend
L'API renvoie les identifiants de corrélation internes et les données de navigation du prestataire :
final readonly class CheckoutResult
{
public function __construct(
public OrderId $orderId,
public PaymentTransactionId $paymentTransactionId,
public bool $paymentRequestAccepted,
public string $message,
public ?string $paymentUrl,
public ?string $providerReference,
public ?string $providerOrderNumber,
) {}
}Pour les paiements par carte, le frontend redirige vers l'URL du prestataire :
const paymentUrl = response.paymentUrl;
if (paymentUrl) {
window.location.href = paymentUrl;
}Pour Mobile Money, le client peut également avoir besoin de confirmer la demande sur son téléphone.
La réponse d'initiation reçue par le frontend n'est pas la source de vérité pour l'accès. Elle peut indiquer que le traitement du paiement a commencé, mais le webhook backend et l'état d’attribution restent les références.
Étape 13 : Créer une passerelle en mémoire qui se comporte comme la véritable frontière
Un faux utile fait plus que renvoyer true.
Ma passerelle en mémoire construit les mêmes formes de requête, renvoie des réponses proches de celles du prestataire et planifie un rappel HTTP vers l'URL signée du webhook.
public function pay(PaymentRequest $request): PaymentResult
{
$status = $this->statusFor($request);
$payload = $this->callbackPayload($request, $status);
$this->scheduleCallback(
$request->callbackUrl,
$payload,
);
return PaymentResult::fromStatus($status, $payload);
}Un numéro de téléphone configuré produit une réussite ; les autres produisent un refus. Une horloge de test fournit les horodatages des rappels.
Cela permet d'exercer le développement local :
Aucun identifiant de paiement réel ni accès à un réseau externe n'est requis.
Le faux hérité était déjà une idée utile, mais il utilisait directement l'horloge système, codait en dur le numéro de réussite et pouvait renvoyer le succès même après avoir construit un état d'échec. Le refactor a rendu sa configuration explicite et a aligné sa réponse sur le résultat du rappel.
Étape 14 : Tester les défaillances métier, pas le SDK du prestataire
Les tests unitaires protègent les transitions de l'agrégat :
$payment->markProcessing($now);
$payment->markSucceeded($later);
self::assertSame(
PaymentStatus::Succeeded,
$payment->status,
);Les tests d'application protègent l'orchestration :
Scénario: Rejouer le même webhook accepté est idempotent
Étant donné qu'une transaction de paiement en attente existe
Quand le même webhook de paiement accepté est traité deux fois
Alors un seul événement du prestataire doit être enregistré
Et la réussite du paiement doit être annoncée une seule foisD'autres scénarios importants incluent :
-
un webhook accepté paie la commande ;
-
un webhook refusé échoue au paiement sans payer la commande ;
-
l'annulation ferme la tentative sans accorder l'accès ;
-
un rappel pour une transaction déjà finale est enregistré mais ne la mute pas ;
-
une exception du prestataire laisse un checkout durable en attente ;
-
les E/S externes s'exécutent en dehors de la transaction de base de données ;
-
l’attribution rejette une commande impayée ;
-
une nouvelle tentative d’attribution ne duplique ni abonnement ni autorisation d'accès ;
-
et un paiement manuel suit la même piste d'audit.
N'appelez pas le véritable environnement FlexPay à partir de la suite de tests automatisés. Testez le mappage de votre adaptateur séparément et testez votre application avec un faux déterministe.
Migration des données de facturation héritées sans importer l'ancien modèle
Le refactor devait également conserver les enregistrements de facturation historiques.
La base de données héritée comportait une ligne de transaction connectée soit à un abonnement, soit à un enregistrement d'accès à vie. Les plans de migration reconstruisent cette intention commerciale et l’étendent au nouveau modèle :
Le mappeur sélectionne ensuite un code produit stable, résout les références actuelles de produit et de prix et conserve les identifiants hérités dans les métadonnées des articles de commande si nécessaire.
Les anciens états sont traduits explicitement :
Les lignes dont l'intention commerciale ne peut pas être classée sont ignorées ou importées en tant qu'historique financier limité selon une règle explicite. Les conflits tels qu'un utilisateur, un produit, un prix ou une commande manquant sont signalés au lieu de créer silencieusement des enregistrements partiels.
Un compromis de compatibilité demeure : les références de transaction utilisent toujours le format compatible hérité pendant la migration des systèmes dépendants. Pour un nouveau système, utilisez une référence publique résistante aux collisions avec une contrainte d'unicité de base de données : de préférence une référence opaque basée sur l'UUID/ULID ou une séquence dédiée conçue pour les références de paiement destinées aux clients.
La leçon plus large est la suivante :
Traduisez les données historiques à la frontière de migration. Ne forcez pas le nouveau domaine à prétendre que l'ancien schéma reste le bon modèle.
Ce que j'ai amélioré par rapport à la conception héritée
Le refactor a modifié les responsabilités, pas seulement les noms de classe.
| Comportement hérité | Comportement refactorisé |
|---|---|
| Abonnement en attente ou enregistrement d'accès doublé en état de paiement | État de paiement propre à la commande et à la transaction de paiement ; le droit est créé après le paiement |
| La transaction mélangeait paiement et type d'achat | Les articles de commande décrivent l'achat ; la transaction de paiement décrit l'encaissement |
| Comportement commercial orchestré par le contrôleur de rappel | Le contrôleur léger délègue une commande d'application normalisée |
| La vérification de l'état en attente constituait la principale défense contre les doublons | Empreinte, contrainte d'unicité, transition d'état idempotente et verrouillage en base de données |
| La charge utile du prestataire existait principalement dans les journaux | L'événement de paiement conserve la preuve du rappel |
| Requête complète et charge utile enregistrées au niveau critique | Les journaux structurés utilisent des identifiants d'entreprise limités ; la charge utile sensible reste en dehors des journaux |
Appels implicites à DateTimeImmutable() | Une horloge injectée fournit les horodatages métier |
| Construction d'URL de rappel codée en dur | Générateur d'URL signées basé sur un routeur dédié |
| Comportement du simulateur local codé en dur | Passerelle en mémoire configurable avec résultats déterministes |
| Un refus supprimait le droit provisoire | Les tentatives infructueuses restent auditables ; aucun droit n'existe avant la réussite |
| Une ligne de transaction devait tout expliquer | La commande, l'article, la tentative de paiement, l'événement du prestataire et le droit ont des rôles distincts |
La nouvelle implémentation n'est pas « plus architecturale » pour des raisons esthétiques. Chaque frontière supplémentaire répond à un mode d’échec de production que l’ancien modèle ne pouvait pas représenter clairement.
Durcissements restants
Une architecture de paiement n'est pas terminée simplement parce que le parcours nominal fonctionne.
Pour une implémentation en production, vérifiez explicitement ces points :
-
seul un rappel serveur vérifié ou une consultation du statut chez le prestataire peut faire réussir un paiement ;
-
la référence de rappel, le montant et la devise correspondent à la transaction stockée ;
-
les références de paiement et les empreintes des événements du prestataire disposent de contraintes d'unicité en base de données ;
-
les secrets sont stockés hors du contrôle de version et renouvelés de manière sûre ;
-
les journaux ne contiennent jamais de jetons, de requêtes de paiement complètes ni de données personnelles inutiles ;
-
le stockage des charges utiles du prestataire applique des contrôles d'accès et des règles de conservation ;
-
les clients HTTP appliquent des délais d'attente bornés pour la connexion et la réponse ;
-
les nouvelles tentatives ne peuvent pas créer une autre commande ni dupliquer un droit ;
-
une tâche de rapprochement peut inspecter les transactions bloquées en attente ou en traitement lorsqu'un webhook est perdu ;
-
les transitions de remboursement mettent à jour les politiques de paiement, de commande et de droit de manière cohérente ;
-
et la diffusion des événements de domaine utilise une outbox ou un mécanisme de fiabilité équivalent lorsque les événements franchissent une frontière de processus.
Certains de ces points sont déjà représentés par le modèle actuel ; d’autres constituent des étapes suivantes explicites. Par exemple, refunded existe dans le vocabulaire des paiements, mais un workflow de remboursement automatisé ne peut être considéré comme complet tant que l'annulation chez le prestataire, les effets sur la commande et la révocation des droits ne sont pas mis en œuvre ensemble.
Être précis sur ce que le système garantit fait partie d'une ingénierie de facturation fiable.
Conclusion
FlexPay est une dépendance d'infrastructure. La facturation est un domaine.
L'adaptateur du prestataire initie les paiements Mobile Money et par carte, analyse les rappels et traduit les échecs. Il ne doit pas décider de ce que le client a acheté, si une commande est exécutée ou comment l'accès à l'abonnement évolue.
L'architecture fonctionne car chaque concept a une tâche :
-
Le produit et le prix définissent ce qui peut être vendu.
-
La commande et l'article de commande préservent l'intention commerciale.
-
La transaction de paiement possède la machine à états de collecte.
-
L'événement de paiement préserve la preuve du prestataire et l'idempotence.
-
Les URL signées mettent en corrélation les rappels avec les transactions internes.
-
La vérification du prestataire établit le résultat du paiement.
-
Les verrous de base de données protègent le traitement simultané des webhooks.
-
Les événements de domaine relient la réussite du paiement à l’attribution.
-
L’attribution idempotente crée l'accès une seule fois.
-
Une passerelle en mémoire exécute l'ensemble du flux de travail localement.
-
Les plans de migration existants traduisent les anciennes données sans contaminer le nouveau modèle.
Le refactoring le plus important consistait à passer d'une transition implicite à un workflow explicite et récupérable :
Ce flux contient plus d'étapes, mais chaque étape rend l'échec visible et la récupération possible.
En matière de facturation, ce n'est pas une complexité accidentelle. C’est le coût pour rendre l’argent et l’accès des clients dignes de confiance.
Bon code !