Une passerelle de paiement régionale. Livrée en tant que plugin. Modèle pull — pas de webhook.

Une passerelle de paiement dans AcelleMail est le pont entre un PaymentIntent sur l'hôte et l'état checkout / abonnement d'un vendor. L'hôte livre huit passerelles dans le package Cashier inclus (Stripe, Stripe Subscription, Braintree, Braintree Subscription, PayPal, Paystack, Razorpay, Offline) ; tout autre vendor — Paddle, Lemon Squeezy, fournisseurs régionaux, rails crypto — est livré en tant que plugin. L'architecture est pull-based : l'hôte récupère l'état depuis le vendor à la demande, aucun écouteur webhook requis. Cette page est l'exemple travaillé utilisant storage/app/plugins/acelle/paddle/ comme référence.

Pourquoi plugin, pas core

Ajouter un vendor en éditant <code>app/Cashier/Services/</code>, le package <code>vendor/acelle/cashier/</code> inclus et <code>app/Providers/CheckoutServiceProvider.php</code> fonctionne en principe. Le chemin plugin a été choisi à la place pour quatre raisons concrètes :

  • Le core reste scellé. Un upgrade core qui ajoute un vendor lie chaque install au coût de boot de ce vendor. Un marchand qui ne vend jamais via Paddle paie tout de même le coût et voit un champ de configuration qu'il ne peut pas utiliser.
  • Livraison indépendante. Un plugin peut itérer au rythme de l'API du vendor — Paddle Billing v2, Razorpay Routes, révisions Adyen Checkout API — sans attendre une release du core.
  • La désinstallation est propre. php artisan plugin:delete acelle/paddle retire le type de passerelle entièrement. Il ne reste pas de branche morte case 'paddle': dans les switchs du core.
  • Politique par tenant. Désactiver le plugin fait disparaître la passerelle de la liste select-type Sending Servers de l'admin partout, immédiatement. Stripe et Offline sont inclus dans le core parce qu'ils sont « toujours actifs » ; tout le reste rentre mieux dans la forme plugin.

Compromis : l'auteur du plugin possède le service de passerelle, le contrôleur de redirect, la vue de formulaire admin, et les mappers côté lecture (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Les contrats fondateurs rendent cela petit — Paddle complet fait environ 500-700 lignes.

Le modèle pull — pas de webhook

L'architecture de passerelle de AcelleMail est pull-based : l'hôte récupère l'état depuis le vendor à la demande. Trois déclencheurs lancent des lectures :

  • Récupération paresseuse au chargement de page — la page abonnement / facture du client appelle getRemoteSubscription au moment du rendu lorsque l'état local est plus ancien que le seuil de fraîcheur.
  • Bouton « Refresh » — admin / client peut forcer une lecture immédiate.
  • Cron périodique RemoteSubscriptionSyncService — balaie chaque abonnement distant actif à une cadence configurable.

Aucun écouteur webhook n'est requis. Les auteurs de plugins n'ont pas besoin d'héberger un endpoint webhook public, de vérifier les signatures HMAC, de dédupliquer la livraison at-least-once, ou de gérer les replay attacks. Le compromis est le décalage d'état — typiquement quelques minutes — entre le vendor confirmant un changement d'état et l'hôte le remarquant. Pour la facturation SaaS, c'est acceptable : le client ne voit pas le nouveau statut d'abonnement à la microseconde près où Paddle le confirme ; il le voit au prochain rendu de page. Les exigences strictes de propagation infra-seconde ne sont pas adaptées à cette architecture sans étendre le contrat.

Pourquoi pull bat push pour la facturation SaaS. Un endpoint public en moins à sécuriser (pas de vérif HMAC, pas de fenêtre de replay, pas d'exigence d'IP publique en dev). Une étape d'intégration en moins pour l'admin lors du setup de la passerelle (pas de « créer l'endpoint, copier le secret, attendre le webhook de test »). Les annulations et changements de plan venant du portail client du vendor se propagent toujours — la prochaine passe de sync les capte. La cadence de sync est configurable dans le core, donc les installs avec des besoins de fraîcheur plus stricts baissent l'intervalle.

Contrats fondateurs dans le core

L'hôte livre quatre pièces de fondation que le plugin consomme. Les plugins ne les implémentent pas — ils les appellent.

Le registre BillingManager

app/Library/BillingManager.php est un singleton lié au DI qui contient la map type-de-passerelle → métadonnées-de-présentation + factory-de-service. Le boot() du ServiceProvider du plugin appelle Billing::register(...) une fois par vendor ; la dropdown select-type côté client, le picker de formulaire admin et Billing::resolveService($gateway) lisent tous depuis ce registre.

Billing::register(
    string  $type,                 // 'paddle' — discriminator slug
    string  $name,                 // shown in admin select-type
    string  $description,          // 1-2 sentences shown alongside the name
    \Closure $serviceFactory,      // fn(PaymentGateway $gw): IntentGatewayInterface
    string  $icon = 'payment',     // Material Symbols Rounded ligature
    bool    $isRemoteSubscription = false,
    string  $formView = '',        // namespaced blade view for the admin gateway-config form
);

Le callback CheckoutHandlerInterface

vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php déclare les callbacks côté hôte que la couche de sync déclenche quand l'état distant diverge de l'état intent local. Les plugins ne l'appellent pas directement. Le RemoteSubscriptionSyncService de l'hôte consomme les méthodes de lecture du plugin et dispatche lui-même vers CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.

La machine à états PaymentIntent

Cinq états terminaux ou en attente ; le plugin ne transitionne jamais directement l'état d'intent. La couche de sync de l'hôte lit l'état vendor via le plugin et bascule l'intent via CheckoutHandler.

ÉtatSignification
PENDINGIntent créé, le client n'a pas encore payé
REQUIRES_ACTIONChallenge 3DS / SCA en attente du client (cartes uniquement)
AWAITING_ADMIN_APPROVALRéclamation offline en attente de revue admin
SUCCEEDEDTerminal — paiement confirmé
FAILED / CANCELLEDTerminal — affiché au client avec la raison du vendor

Le package Cashier — huit passerelles intégrées en référence

Le package Cashier forké à /Users/luan/apps/cashier/src/Services/ livre huit implémentations de passerelle intégrées. Les lire est le moyen le plus rapide de voir la surface complète qu'un plugin de passerelle de paiement pourrait vouloir implémenter : StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Les deux premiers sont en forme d'abonnement ; les autres sont des paiements ponctuels par carte ou de type virement.

Quatre interfaces de capacité

Les quatre vivent dans le package Cashier sous /Users/luan/apps/cashier/src/Contracts/. La classe de passerelle du plugin n'implémente que les interfaces que son vendor supporte réellement — l'hôte fait des vérifications instanceof à chaque site d'appel.

InterfaceRôleRequise pour
IntentGatewayInterface Contrat de base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo pour l'affichage de méthode enregistrée Chaque passerelle
SupportsAutoChargeInterface autoCharge(intent, pmData) pour le débit off-session de carte sans redirect Passerelles avec re-débit one-tap (Stripe one-off ; Paddle ne le fait pas)
SupportsSubscriptionInterface createSubscription(intent, pmData) pour la création d'abonnement headless Passerelles supportant un flux d'abonnement headless (Stripe Subscription) ; le createSubscription de Paddle lève parce que Paddle est hosted-checkout uniquement
RemoteSubscriptionGatewayInterface getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — le côté lecture/sync Passerelles où le vendor possède l'état d'abonnement (Paddle, Stripe Subscription)

Scaffold du plugin

La disposition complète des fichiers pour un plugin de passerelle de paiement :

storage/app/plugins/{author}/{name}/
├── composer.json                              ← plugin metadata + autoload + provider
├── routes.php                                 ← icon route + /cashier/{vendor}/checkout/{intent_uid}
├── icon.svg                                   ← admin Plugins page icon
├── src/
│   ├── ServiceProvider.php                    ← Billing::register + lifecycle hooks
│   ├── Services/
│   │   └── {Vendor}Gateway.php                ← implements IntentGateway[+ optional capability ifaces]
│   ├── Controllers/
│   │   └── {Vendor}CheckoutController.php     ← redirect to vendor's hosted checkout
│   └── Support/
│       └── {Vendor}Api.php                    ← Guzzle wrapper around vendor REST API
├── database/migrations/                       ← empty unless plugin owns its own table
├── resources/
│   ├── views/
│   │   └── form.blade.php                     ← admin gateway-config form (api keys, environment)
│   └── lang/
│       └── en/messages.php                    ← gateway name, description, form labels
└── tests/Unit/                                ← unit tests (capability contract)

Remarquez ce qui n'est pas là : pas de contrôleur webhook, pas de vérificateur de signature, pas de table de protection contre les replays. La sync d'état est pull-ée par l'hôte, pas push-ée par le vendor.

ServiceProvider — un seul appel Billing::register enregistre tout

Le ServiceProvider squelette complet pour un plugin de passerelle de paiement (paraphrasé depuis acelle/paddle) :

namespace Acelle\Paddle;

class ServiceProvider extends Base
{
    public function register(): void
    {
        // Translation file — MUST be in register(), not boot. See /developers/translations.
        Hook::add('add_translation_file', fn () => [
            'translation_prefix' => 'paddle',
            'master_translation_file' => realpath(__DIR__.'/../resources/lang/en/messages.php'),
            // ...
        ]);
    }

    public function boot(): void
    {
        $this->loadViewsFrom(__DIR__.'/../resources/views', 'paddle');
        $this->loadRoutesFrom(__DIR__.'/../routes.php');

        // The single registry call.
        Billing::register(
            'paddle',
            trans('paddle::messages.gateway.name'),
            trans('paddle::messages.gateway.description'),
            fn ($gw) => new Services\PaddleGateway(
                apiKey:      (string) $gw->getGatewayData('api_key'),
                environment: (string) ($gw->getGatewayData('environment') ?: 'sandbox'),
            ),
            icon: 'rocket_launch',
            isRemoteSubscription: true,
            formView: 'paddle::form',
        );

        // Lifecycle — same pattern as every plugin.
        Hook::on('activate_plugin_acelle/paddle', fn () => \Artisan::call('migrate', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
        Hook::on('delete_plugin_acelle/paddle', fn () => \Artisan::call('migrate:rollback', [
            '--path'  => 'storage/app/plugins/acelle/paddle/database/migrations',
            '--force' => true,
        ]));
    }
}

La closure dans Billing::register lit les credentials depuis la colonne JSON gatewayData du PaymentGateway via getGatewayData('key'). C'est ainsi que les champs de formulaire de l'admin s'écoulent dans le constructeur du service.

Service passerelle — getCheckoutUrl renvoie l'URL du plugin

L'hôte appelle getCheckoutUrl au moment où le client s'engage sur un checkout. L'implémentation doit être bon marché et sans effet de bord — pas d'appels API vendor pendant le rendu :

public function getCheckoutUrl(PaymentIntent $intent, string $returnUrl): string
{
    return route('paddle.checkout', ['intent_uid' => $intent->uid])
        . '?return_url=' . urlencode($returnUrl);
}

L'URL pointe vers le propre contrôleur du plugin, pas directement vers l'URL du vendor. Trois raisons :

  • L'appel API vendor pour créer le hosted-checkout réel (Paddle : POST /transactions) doit vivre derrière une frontière de contrôleur pour que les erreurs puissent être attrapées et que le client puisse être redirigé vers la page de facture avec un flash d'erreur.
  • Le logging et le throttling appartiennent au contrôleur, pas au service de passerelle.
  • Le service de passerelle reste « pur » — pas d'effets de bord HTTP lorsque getCheckoutUrl s'exécute au moment de la création de l'intent. getCheckoutUrl peut être appelé de manière spéculative, y compris dans les tests.

Contrôleur de checkout — appel vendor + 302 vers hosted checkout

Le CheckoutController::redirect() du plugin est l'endroit où l'appel API vendor se produit réellement. Le client tape /cashier/paddle/checkout/{uid}, le contrôleur appelle l'endpoint POST /transactions de Paddle, et fait un 302 du navigateur vers l'URL de hosted checkout renvoyée par Paddle :

public function redirect(Request $request, string $intentUid)
{
    $intent  = PaymentIntent::where('uid', $intentUid)->firstOrFail();
    $service = Billing::resolveService($intent->paymentGateway);
    $returnUrl = (string) $request->query('return_url', url('/'));

    try {
        $tx = $service->createCheckoutTransaction(
            intentUid:     $intent->uid,
            currency:      (string) $intent->currency,
            amountMajor:   (float)  $intent->amount,
            customerEmail: $intent->invoice->billing_email,
            remotePriceId: $intent->metadata['remote_plan_id'] ?? null,
            returnUrl:     $returnUrl,
        );
    } catch (\Throwable $e) {
        \Log::error('Paddle checkout: createTransaction failed', [
            'intent_uid' => $intent->uid,
            'error'      => $e->getMessage(),
        ]);
        return redirect()->away($returnUrl)
            ->with('alert-error', trans('paddle::messages.checkout.create_failed', [
                'error' => $e->getMessage(),
            ]));
    }

    return redirect()->away($tx['data']['checkout']['url']);
}

createCheckoutTransaction est une méthode interne au plugin sur PaddleGateway (pas sur une quelconque interface). Elle enveloppe le POST /transactions du vendor avec la forme JSON spécifique à Paddle.

Critique : custom_data.intent_uid est envoyé au vendor et renvoyé en écho sur les lectures suivantes — GET /subscriptions/{id} renvoie le même custom_data. C'est ainsi que la couche de sync mappe un abonnement Paddle à un PaymentIntent local. Perdez ceci et la sync échoue silencieusement.

Mappers côté lecture — alimenter la couche de sync

Le plugin expose l'état vendor via les méthodes RemoteSubscriptionGatewayInterface. Le RemoteSubscriptionSyncService de l'hôte (cron + à la demande) les appelle pour rafraîchir les DTOs locaux, puis dispatche vers CheckoutHandler quand l'état diverge :

public function getRemoteSubscription(string $remoteSubId): RemoteSubscriptionDTO
{
    $response = $this->api->get("/subscriptions/{$remoteSubId}");
    return $this->subscriptionToDto($response['data'] ?? []);
}

public function getRemoteSubscriptions(?string $startingAfter = null, int $limit = 100): array
{
    $query = ['per_page' => min($limit, 200)];
    if ($startingAfter) {
        $query['after'] = $startingAfter;
    }
    $response = $this->api->get('/subscriptions', $query);
    return [
        'data'        => array_map([$this, 'subscriptionToDto'], $response['data'] ?? []),
        'has_more'    => $response['has_more'] ?? false,
        'next_cursor' => $response['next_cursor'] ?? null,
    ];
}

Contrat de pagination : renvoyer {data, has_more, next_cursor}. Le service de sync parcourt les pages jusqu'à has_more => false. Les mappers DTO (priceToDto, subscriptionToDto) traduisent la forme JSON du vendor dans la forme DTO neutre de l'hôte — c'est le savoir par vendor qui est le plus difficile à partager, parce que la forme de réponse de chaque vendor diffère.

Matrice de capacités — quatre patterns de vendor

Les interfaces qu'un plugin implémente dépendent du modèle de paiement du vendor. Les quatre patterns que l'hôte supporte déjà :

Pattern de vendorImplémenteExemples
Débit carte ponctuel avec token IntentGatewayInterface + SupportsAutoChargeInterface Stripe one-off, Square, Razorpay one-off
Abonnement hosted-checkout IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lève) Paddle, Lemon Squeezy
Abonnement headless IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface Stripe Subscription, Braintree Subscription
Manuel / virement IntentGatewayInterface uniquement Offline (virement bancaire, espèces)

N'implémentez pas les interfaces inutilisées. Billing::supportsRemoteSubscription($gw) lit le flag isRemoteSubscription positionné au moment de l'enregistrement, pas via instanceof — mais le flag doit correspondre à ce que votre service fait réellement, alors gardez-les alignés.

Tester le contrat de capacité

Sans code de webhook, il n'y a pas de frontière de sécurité à tester unitairement. Concentrez-vous sur le contrat de capacité — les choses dont les appelants dépendent comme garanties de la forme de la passerelle.

Choses à tester unitairement

  • createSubscription lève pour les vendors hosted-checkout uniquement (Paddle) — les appelants savent qu'il faut utiliser getCheckoutUrl à la place.
  • getCheckoutUrl renvoie une route vers le contrôleur de votre plugin, pas une URL vendor — prouve que la frontière de contrôleur est en place.
  • getRemoteSubscription renvoie un RemoteSubscriptionDTO rempli lorsque le vendor renvoie une forme connue — exercez subscriptionToDto avec une fixture enregistrée.
  • Contrat de pagination — getRemoteSubscriptions avec plusieurs pages parcourt jusqu'à has_more => false et renvoie les données concaténées.

Choses à ne pas tester unitairement

  • Réponses API vendor en live — celles-ci nécessitent un véritable compte sandbox et sont hors du périmètre des tests unitaires. Vérifiez en live avant de livrer (curl-er les endpoints avec une clé sandbox).
  • Mappers DTO lorsqu'ils sont privés et étroitement couplés à la forme JSON vendor — couvrez-les indirectement via des tests de bout en bout avec des fixtures enregistrées, ou exposez-les uniquement pour les tests lorsque la logique de mapping est non triviale.
  • Routing — Laravel couvre loadRoutesFrom ; le nom de route se résout au runtime tant que la closure le capture.
  • Chemin nominal Billing::register — le propre BillingManagerTest de l'hôte le couvre.

Enregistrez la testsuite du plugin dans le phpunit.xml de l'hôte selon le deep-dive Tests : <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Lancez la suite avec ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".

Cycle de vie d'activation

ÉvénementCe qui se passe
php artisan plugin:init author/nameFichiers générés sous storage/app/plugins/.... Ligne en base insérée avec status=inactive. ServiceProvider autoloadé.
L'admin clique sur ActivateDéclenche activate_plugin_author/name → le hook du plugin exécute les migrations. Le statut en base bascule à active. La passerelle est désormais visible dans la dropdown select-type.
L'admin clique sur DeactivateLe statut en base bascule à inactive. Le ServiceProvider reste chargé. Les routes se résolvent toujours. Le type de passerelle est toujours dans BillingManager jusqu'au prochain boot de processus.
L'admin clique sur DeleteDéclenche delete_plugin_author/name → le hook du plugin rollback les migrations. Fichiers retirés, ligne en base supprimée, entrée du fichier master effacée.

Garde pour la sémantique de désactivation : si « désactiver = la passerelle disparaît immédiatement » importe pour votre install, vérifiez Plugin::getByName('myvendor/myplugin')->isActive() à l'intérieur de la closure factory-de-service de Billing::register et levez sinon. Le plugin acelle/paddle inclus ne le fait actuellement pas — la passerelle reste disponible même quand un admin la désactive (jusqu'au prochain redémarrage de processus). Hardening futur ; pour le moment le pattern vit dans Architecture des plugins § Pourquoi les plugins inactifs affectent quand même l'app.

Discipline de frontière vendor

Voici les patterns que les tests d'état local seuls ratent. Chacun est venu d'un véritable bug livré avant d'être attrapé par une vérification yeux humains sur écran. Lisez cette section avant d'écrire le service de passerelle.

1. Passez explicitement les champs portant des unités — ne comptez jamais sur les valeurs par défaut du vendor

Amount seul ne suffit pas. Les vendors interprètent le montant comme des unités mineures d'une devise ; si le plugin ne passe pas Currency, le vendor retombe sur la devise par défaut du terminal ou du compte. Le plugin TBANK (référence sœur) omettait à l'origine Currency de son payload Init — la devise par défaut du terminal était RUB, le plan était en USD, le client a vu « 49 ₽ » tandis que le marchand croyait avoir débité « 49 $ ». Le bug était invisible aux assertions DB locales parce que l'intent local enregistrait fidèlement currency='USD' ; seul l'affichage du vendor disait la vérité.

// ❌ Silent default
$payload = ['Amount' => $amountMinor, 'OrderId' => $intentUid];

// ✅ Explicit, fail-loud on unknown currency
private const ISO4217_NUMERIC = ['RUB' => '643', 'USD' => '840', 'EUR' => '978', /* ... */];

if (!isset(self::ISO4217_NUMERIC[$iso = strtoupper($currency)])) {
    throw new \InvalidArgumentException(
        "Unsupported currency '{$currency}'. Supported: " .
        implode(', ', array_keys(self::ISO4217_NUMERIC))
    );
}
$payload['Currency'] = self::ISO4217_NUMERIC[$iso];

2. Re-mappez custom_data à travers chaque endpoint de lecture

Le plugin envoie custom_data.intent_uid à la création du checkout. Le vendor le renvoie en écho sur chaque lecture liée — détail d'abonnement, détail de transaction, détail de méthode de paiement. Le plugin doit le relire depuis chaque forme parce que l'emplacement custom_data du vendor peut différer entre endpoints de lecture. Perdez le mapping dans l'un d'entre eux et la sync laisse silencieusement cet intent en PENDING à jamais.

3. Ne levez jamais une exception non gérée hors de getCheckoutUrl

getCheckoutUrl est appelée durant le rendu de page. Une levée non gérée produit une page 500 là où le client attendait un bouton de checkout. Gardez la méthode sans effet de bord ; laissez le contrôleur gérer les échecs d'appel vendor et faire remonter l'erreur dans la session flash du client.

4. Attrapez chaque appel vendor dans le contrôleur et flashez l'erreur

Les clients ont besoin de voir ce que le vendor a dit pour pouvoir choisir une autre passerelle ou contacter le support. Une page 500 ne leur dit rien. Le try / catch + redirect()->away($returnUrl)->with('alert-error', ...) du contrôleur Paddle est le pattern canonique.

5. Loggez chaque appel vendor avec l'UID d'intent

Sans l'UID d'intent dans la ligne de log, déboguer un échec rapporté par un client signifie défiler à travers des centaines de lignes de log non liées. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) est la forme minimale utile ; les équipes faisant tourner plusieurs passerelles ajoutent aussi un tag 'gateway' => 'paddle'.

Où aller ensuite

Les drivers d'envoi (modèle push avec webhooks) et les passerelles de paiement (modèle pull avec lectures) sont les deux pages d'exemple travaillé les plus lourdes. Ensemble, elles couvrent les deux côtés du spectre frontière-vendor que l'hôte supporte aujourd'hui. La page suivante — la vitrine acelle/ai — parcourt le plugin complexe canonique de bout en bout comme exercice de compréhension de lecture : huit modèles Eloquent, quatorze migrations, dix-huit locales, la surface UI chatbox, chaque pattern de hook en production. Utilisez-la comme base de code de référence quand vous êtes prêt à construire quelque chose de plus grand qu'un driver ou une passerelle.

Quand la passerelle est livrée et en live, exécutez le cycle activate → test → delete du deep-dive Tests — il attrape une classe de bugs de hook delete_plugin_* que les tests unitaires ne peuvent pas. Pour les références cross-page plus larges, l'aperçu Architecture des plugins a la carte complète du cycle de vie.