Un payment gateway regionale. Rilasciato come plugin. Modello pull — no webhook.

Un payment gateway in AcelleMail è il ponte tra un PaymentIntent sull'host e lo stato di checkout / subscription di un vendor. L'host rilascia otto gateway nel package Cashier in bundle (Stripe, Stripe Subscription, Braintree, Braintree Subscription, PayPal, Paystack, Razorpay, Offline); ogni altro vendor — Paddle, Lemon Squeezy, provider regionali, rail crypto — viene rilasciato come plugin. L'architettura è pull-based: l'host pesca lo stato dal vendor on demand, nessun webhook listener richiesto. Questa pagina è l'esempio pratico che usa storage/app/plugins/acelle/paddle/ come riferimento.

Perché plugin, non core

Aggiungere un vendor modificando <code>app/Cashier/Services/</code>, il package in bundle <code>vendor/acelle/cashier/</code> e <code>app/Providers/CheckoutServiceProvider.php</code> funziona in linea di principio. La via plugin è stata scelta invece per quattro motivi concreti:

  • Il core resta sigillato. Un upgrade core che aggiunge un vendor lega ogni install al costo di boot di quel vendor. Un merchant che non vende mai tramite Paddle paga comunque il costo e vede un campo di configurazione che non può usare.
  • Rilascio indipendente. Un plugin può iterare al ritmo dell'API del vendor — Paddle Billing v2, Razorpay Routes, revisioni dell'Adyen Checkout API — senza aspettare una release del core.
  • L'uninstall è pulito. php artisan plugin:delete acelle/paddle rimuove completamente il tipo di gateway. Non resta alcun ramo case 'paddle': morto negli switch del core.
  • Policy per-tenant. Disabilitare il plugin fa sparire il gateway dalla lista select-type Sending Servers dell'admin ovunque, immediatamente. Stripe e Offline sono in bundle nel core perché sono "always-on"; tutto il resto si adatta meglio alla forma plugin.

Trade-off: l'autore del plugin possiede il gateway service, il controller di redirect, la view del form admin e i mapper read-side (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). I contratti di fondazione lo rendono piccolo — Paddle completo è all'incirca 500-700 righe.

Il modello pull — no webhook

L'architettura gateway di AcelleMail è pull-based: l'host pesca lo stato dal vendor on demand. Tre trigger eseguono read:

  • Lazy fetch al page-load — la pagina subscription / invoice del cliente chiama getRemoteSubscription al momento del render quando lo stato locale è più vecchio della soglia di freschezza.
  • Pulsante "Refresh" — admin / cliente possono forzare una lettura immediata.
  • Cron periodico RemoteSubscriptionSyncService — passa in rassegna ogni subscription remota attiva con una cadenza configurabile.

Nessun webhook listener è richiesto. Gli autori di plugin non devono ospitare un endpoint webhook pubblico, verificare firme HMAC, deduplicare consegne at-least-once o gestire replay attack. Il trade-off è il lag di stato — tipicamente minuti — tra il vendor che conferma un cambio di stato e l'host che se ne accorge. Per il billing SaaS questo va bene: il cliente non vede il nuovo status di subscription nel microsecondo letterale in cui Paddle lo conferma; lo vede al prossimo render di pagina. Requisiti hard per propagazione sub-secondo non sono adatti a questa architettura senza estendere il contratto.

Perché pull batte push per il billing SaaS. Un endpoint pubblico in meno da mettere in sicurezza (no verifica HMAC, nessuna finestra di replay, nessun requisito di IP pubblico in dev). Uno step di integrazione in meno per l'admin durante il setup del gateway (niente balletto "crea endpoint, copia il secret, aspetta il webhook di test"). Cancellazioni e cambi piano provenienti dal customer portal del vendor si propagano comunque — il prossimo sync pass li prende. La cadenza di sync è configurabile nel core, quindi gli install con requisiti di freschezza più stretti abbassano l'intervallo.

Contratti di fondazione nel core

L'host fornisce quattro pezzi di fondazione che il plugin consuma. I plugin non li implementano — li chiamano.

Il registry BillingManager

app/Library/BillingManager.php è un singleton bound via DI che mantiene la mappa gateway-type → presentation-metadata + service-factory. Il boot() del service provider del plugin chiama Billing::register(...) una volta per vendor; la dropdown select-type customer-facing, il picker del form admin e Billing::resolveService($gateway) leggono tutti da questo registry.

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
);

Il callback CheckoutHandlerInterface

vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php dichiara i callback host-side che il sync layer lancia quando lo stato remoto diverge dallo stato di intent locale. I plugin non lo chiamano direttamente. Il RemoteSubscriptionSyncService dell'host consuma i metodi di lettura del plugin e fa dispatch lui stesso verso CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived.

La state machine PaymentIntent

Cinque stati terminal-or-pending; il plugin non transitiona mai lo stato di intent direttamente. Il sync layer dell'host legge lo stato del vendor tramite il plugin e ribalta l'intent tramite CheckoutHandler.

StatoSignificato
PENDINGIntent creato, il cliente non ha ancora pagato
REQUIRES_ACTIONSfida 3DS / SCA in attesa del cliente (solo card)
AWAITING_ADMIN_APPROVALClaim offline in attesa di revisione admin
SUCCEEDEDTerminale — pagamento confermato
FAILED / CANCELLEDTerminale — mostrato al cliente con la ragione del vendor

Il package Cashier — otto gateway built-in di riferimento

Il fork del package Cashier in /Users/luan/apps/cashier/src/Services/ fornisce otto implementazioni gateway built-in. Leggerle è il modo più rapido per vedere la superficie completa che un plugin payment-gateway potrebbe voler implementare: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. I primi due sono subscription-shaped; il resto sono one-off card o wire-style.

Quattro capability interface

Tutte e quattro vivono nel package Cashier sotto /Users/luan/apps/cashier/src/Contracts/. La classe gateway del plugin implementa solo le interface che il suo vendor supporta davvero — l'host fa check instanceof in ogni call site.

InterfaceScopoRichiesto per
IntentGatewayInterface Contratto base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo per la display del payment method salvato Ogni gateway
SupportsAutoChargeInterface autoCharge(intent, pmData) per addebito card off-session senza redirect Gateway con re-bill one-tap (Stripe one-off; Paddle non)
SupportsSubscriptionInterface createSubscription(intent, pmData) per la creazione di subscription headless Gateway che supportano un flow di subscription headless (Stripe Subscription); il createSubscription di Paddle lancia perché Paddle è solo hosted-checkout
RemoteSubscriptionGatewayInterface getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — il lato read/sync Gateway dove il vendor possiede lo stato di subscription (Paddle, Stripe Subscription)

Scaffold del plugin

Il file layout completo per un plugin payment-gateway:

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)

Nota cosa non c'è: nessun controller webhook, nessun verificatore di signature, nessuna tabella di replay-protection. Il sync di stato è pulled dall'host, non pushed dal vendor.

ServiceProvider — una sola chiamata Billing::register registra tutto

Lo scheletro completo del service provider per un plugin payment-gateway (parafrasato da 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 dentro Billing::register legge le credenziali dalla colonna JSON gatewayData del PaymentGateway tramite getGatewayData('key'). È così che i campi del form admin fluiscono nel costruttore del service.

Gateway service — getCheckoutUrl restituisce l'URL del plugin

L'host chiama getCheckoutUrl nel momento in cui il cliente si impegna a un checkout. L'implementazione deve essere economica e side-effect-free — nessuna chiamata API al vendor durante il render:

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

L'URL punta al controller del plugin, non direttamente all'URL del vendor. Tre motivi:

  • La chiamata API al vendor per creare l'hosted-checkout effettivo (Paddle: POST /transactions) deve vivere dietro un boundary di controller così che gli errori possano essere catturati e il cliente possa essere redirettato alla pagina invoice con un flash di errore.
  • Logging e throttling stanno sul controller, non nel gateway service.
  • Il gateway service resta "puro" — nessun side effect HTTP quando getCheckoutUrl gira al momento di creazione dell'intent. getCheckoutUrl può essere chiamato speculativamente, inclusi i test.

Controller di checkout — chiamata vendor + 302 verso l'hosted checkout

Il CheckoutController::redirect() del plugin è dove la chiamata API al vendor avviene davvero. Il cliente colpisce /cashier/paddle/checkout/{uid}, il controller chiama l'endpoint Paddle POST /transactions e fa 302 del browser verso l'URL hosted checkout che Paddle ha restituito:

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 è un metodo interno al plugin su PaddleGateway (non su alcuna interface). Avvolge il POST /transactions del vendor con la forma JSON specifica di Paddle.

Critico: custom_data.intent_uid viene inviato al vendor e rispedito nelle letture successive — GET /subscriptions/{id} restituisce lo stesso custom_data. È così che il sync layer mappa una subscription Paddle su un PaymentIntent locale. Perdilo e il sync fallisce silenziosamente.

Mapper read-side — alimentare il sync layer

Il plugin espone lo stato del vendor tramite i metodi di RemoteSubscriptionGatewayInterface. Il RemoteSubscriptionSyncService dell'host (cron + on-demand) li chiama per refresh-are i DTO locali, poi fa dispatch verso CheckoutHandler quando lo stato 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,
    ];
}

Contratto di paginazione: restituisci {data, has_more, next_cursor}. Il sync service cammina le pagine fino a has_more => false. I mapper DTO (priceToDto, subscriptionToDto) traducono la forma JSON del vendor nella forma DTO neutrale dell'host — è la conoscenza per-vendor più difficile da condividere, perché la forma di risposta di ogni vendor differisce.

Capability matrix — quattro pattern di vendor

Le interface che un plugin implementa dipendono dal modello di pagamento del vendor. I quattro pattern che l'host supporta già:

Pattern vendorImplementaEsempi
Addebito one-off con card token IntentGatewayInterface + SupportsAutoChargeInterface Stripe one-off, Square, Razorpay one-off
Subscription con hosted-checkout IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lancia) Paddle, Lemon Squeezy
Subscription headless IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface Stripe Subscription, Braintree Subscription
Manuale / bonifico bancario Solo IntentGatewayInterface Offline (bonifico, contanti)

Non implementare interface inutilizzate. Billing::supportsRemoteSubscription($gw) legge il flag isRemoteSubscription impostato al momento della registrazione, non tramite instanceof — ma il flag deve combaciare con ciò che il tuo service fa davvero, quindi tienili allineati.

Testare il contratto delle capability

Senza codice webhook, non c'è alcun boundary di sicurezza da unit-testare. Concentrati sul contratto delle capability — le cose che i caller danno per garantite sulla forma del gateway.

Cose da unit-testare

  • createSubscription lancia per vendor solo hosted-checkout (Paddle) — i caller sanno di usare getCheckoutUrl invece.
  • getCheckoutUrl restituisce una route verso il controller del tuo plugin, non un URL del vendor — prova che il boundary di controller è al suo posto.
  • getRemoteSubscription restituisce un RemoteSubscriptionDTO popolato quando il vendor restituisce una forma nota — esercita subscriptionToDto con una fixture registrata.
  • Contratto di paginazione — getRemoteSubscriptions con più pagine cammina fino a has_more => false e restituisce i dati concatenati.

Cose da non unit-testare

  • Risposte live dell'API vendor — richiedono un account sandbox reale e sono fuori scope per i unit test. Verifica live prima del rilascio (curl degli endpoint con una chiave sandbox).
  • Mapper DTO quando sono privati e tightly coupled alla forma JSON del vendor — coprili indirettamente tramite test end-to-end con fixture registrate, o esponili solo per il test quando la logica di mapping è non banale.
  • Routing — Laravel copre loadRoutesFrom; il route name si risolve a runtime finché la closure lo cattura.
  • Happy path di Billing::register — il BillingManagerTest dell'host stesso lo copre.

Registra la testsuite del plugin nel phpunit.xml dell'host come da deep-dive Testing: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Lancia la suite con ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".

Lifecycle di attivazione

EventoCosa succede
php artisan plugin:init author/nameFile generati sotto storage/app/plugins/.... Riga DB inserita con status=inactive. Service provider auto-caricato.
L'admin clicca ActivateLancia activate_plugin_author/name → l'hook del plugin esegue le migration. Lo status DB passa ad active. Il gateway è ora visibile nel dropdown select-type.
L'admin clicca DeactivateLo status DB passa a inactive. Il service provider resta caricato. Le route si risolvono comunque. Il tipo di gateway è ancora in BillingManager fino al prossimo boot di processo.
L'admin clicca DeleteLancia delete_plugin_author/name → l'hook del plugin fa rollback delle migration. File rimossi, riga DB cancellata, entry del master file pulita.

Gate per la semantica deactivate: se "deactivate = gateway sparisce immediatamente" conta per il tuo install, controlla Plugin::getByName('myvendor/myplugin')->isActive() dentro la closure service-factory di Billing::register e lancia se no. Il plugin in bundle acelle/paddle attualmente non lo fa — il gateway resta disponibile anche quando un admin lo deattiva (fino al prossimo restart di processo). Hardening futuro; per ora il pattern vive in Plugin architecture § Perché i plugin inattivi influenzano comunque l'app.

Disciplina del confine col vendor

Questi sono i pattern che i test solo-stato-locale mancano. Ognuno è arrivato da un bug reale rilasciato prima di essere intercettato da un check humans-eyes-on-screen. Leggi questa sezione prima di scrivere il gateway service.

1. Passa esplicitamente i campi con unità — non affidarti mai ai default del vendor

Amount da solo non basta. I vendor interpretano amount come minor units di qualche valuta; se il plugin non passa Currency, il vendor ricade sul default del terminale o dell'account. Il plugin TBANK (riferimento sibling) originariamente ometteva Currency dal suo payload Init — il default del terminale era RUB, il piano era USD, il cliente vedeva "₽49" mentre il merchant credeva di aver addebitato "$49". Il bug era invisibile alle asserzioni DB locali perché l'intent locale registrava diligentemente currency='USD'; solo la visualizzazione del vendor diceva la verità.

// ❌ 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. Mappa custom_data in tutti gli endpoint di lettura

Il plugin invia custom_data.intent_uid alla creazione del checkout. Il vendor lo rispedisce in ogni lettura correlata — dettaglio subscription, dettaglio transaction, dettaglio payment-method. Il plugin deve rileggerlo da ogni forma perché la posizione di custom_data del vendor può differire tra endpoint di lettura. Perdi il mapping in uno solo e il sync lascia silenziosamente quell'intent in PENDING per sempre.

3. Non lanciare mai un'eccezione non gestita fuori da getCheckoutUrl

getCheckoutUrl viene chiamato durante il render della pagina. Un throw non gestito produce una pagina 500 dove il cliente si aspettava un pulsante di checkout. Mantieni il metodo side-effect free; lascia che il controller gestisca i fallimenti delle chiamate al vendor e mostri l'errore nella flash session del cliente.

4. Cattura ogni chiamata al vendor nel controller e fai flash dell'errore

I clienti devono vedere ciò che ha detto il vendor così possono scegliere un gateway diverso o contattare il supporto. Una pagina 500 non dice nulla. Il try / catch + redirect()->away($returnUrl)->with('alert-error', ...) del controller Paddle è il pattern canonico.

5. Logga ogni chiamata al vendor con l'intent UID

Senza l'intent UID nella riga di log, fare debug su un fallimento riportato dal cliente significa scrollare centinaia di righe di log non correlate. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) è la forma minima utile; i team che gestiscono più gateway aggiungono anche un tag 'gateway' => 'paddle'.

Dove andare ora

Sending driver (modello push con webhook) e payment gateway (modello pull con read) sono le due pagine di esempio pratico più pesanti. Insieme coprono entrambi i lati dello spettro del confine col vendor che l'host supporta oggi. La prossima pagina — lo showcase acelle/ai — percorre il plugin complesso canonico end-to-end come esercizio di lettura: otto model Eloquent, quattordici migration, diciotto locale, la superficie UI chatbox e ogni pattern di hook in produzione. Usalo come codebase di riferimento quando sei pronto a costruire qualcosa più grande di un driver o di un gateway.

Quando il gateway è rilasciato e live, esegui il ciclo activate → test → delete dal deep-dive Testing — intercetta una classe di bug degli hook delete_plugin_* che i unit test non possono. Per i riferimenti cross-page più ampi, l'overview Plugin architecture ha la mappa completa del lifecycle.