Um gateway de pagamento regional. Entregue como plugin. Modelo pull — sem webhook.

Um gateway de pagamento no AcelleMail é a ponte entre um PaymentIntent no host e o estado de checkout / assinatura de um fornecedor. O host entrega oito gateways no pacote Cashier embutido (Stripe, Stripe Subscription, Braintree, Braintree Subscription, PayPal, Paystack, Razorpay, Offline); todo outro fornecedor — Paddle, Lemon Squeezy, provedores regionais, trilhos crypto — entrega como plugin. A arquitetura é pull-based: o host busca estado do fornecedor sob demanda, sem listener de webhook necessário. Esta página é o exemplo trabalhado usando storage/app/plugins/acelle/paddle/ como referência.

Por que plugin, não core

Adicionar um fornecedor editando <code>app/Cashier/Services/</code>, o pacote embutido <code>vendor/acelle/cashier/</code> e <code>app/Providers/CheckoutServiceProvider.php</code> funciona em princípio. O caminho de plugin foi escolhido por quatro razões concretas:

  • Core fica selado. Um upgrade do core que adiciona um fornecedor amarra toda instalação ao custo de boot daquele fornecedor. Um merchant que nunca vende pelo Paddle ainda paga o custo e vê um campo de configuração que não pode usar.
  • Entrega independente. Um plugin pode iterar no ritmo da API do fornecedor — Paddle Billing v2, Razorpay Routes, revisões da Adyen Checkout API — sem esperar um release do core.
  • Uninstall é limpo. php artisan plugin:delete acelle/paddle remove o tipo de gateway inteiramente. Não fica um branch case 'paddle': morto nos switches do core.
  • Política por tenant. Desabilitar o plugin faz o gateway sumir da lista select-type de Servidores de Envio do admin em todo lugar, imediatamente. Stripe e Offline são embutidos no core porque são "sempre-ligados"; todo o resto cabe melhor no formato de plugin.

Trade-off: o autor do plugin é dono do gateway service, do controller de redirect, da view de form admin e dos mappers do lado leitura (getRemotePlan / getRemoteSubscription / getRemotePaymentMethod). Os contratos de fundação tornam isso pequeno — Paddle inteiro são cerca de 500-700 linhas.

O modelo pull — sem webhook

A arquitetura de gateway do AcelleMail é pull-based: o host busca estado do fornecedor sob demanda. Três gatilhos rodam leituras:

  • Fetch lazy de page-load — a página de assinatura / fatura do cliente chama getRemoteSubscription em tempo de render quando o estado local é mais velho que o threshold de freshness.
  • Botão "Atualizar" — admin / cliente pode forçar uma leitura imediata.
  • Cron periódico RemoteSubscriptionSyncService — varre cada assinatura remota ativa em uma cadência configurável.

Nenhum listener de webhook é exigido. Autores de plugin não precisam hospedar um endpoint público de webhook, verificar assinaturas HMAC, deduplicar entrega at-least-once ou tratar ataques de replay. O trade-off é lag de estado — tipicamente minutos — entre o fornecedor confirmar uma mudança de estado e o host notar. Para billing SaaS isso está bem: o cliente não vê o novo status da assinatura no microsegundo literal que o Paddle confirma; vê no próximo render de página. Requisitos duros para propagação sub-segundo não combinam com essa arquitetura sem estender o contrato.

Por que pull bate push para billing SaaS. Um endpoint público a menos para proteger (sem verificação HMAC, sem janela de replay, sem requisito de IP público em dev). Um passo de integração a menos para o admin durante setup do gateway (sem a dança "crie endpoint, copie o segredo, espere o webhook de teste"). Cancelamentos e mudanças de plano vindo do portal de cliente do fornecedor ainda propagam — o próximo passo de sync pega. A cadência de sync é configurável no core, então instalações com requisitos de freshness mais estritos abaixam o intervalo.

Contratos de fundação no core

O host entrega quatro peças de fundação que o plugin consome. Plugins não implementam essas — eles chamam.

O registry BillingManager

app/Library/BillingManager.php é um singleton DI-bound que segura o mapa tipo-de-gateway → metadata-de-apresentação + factory-de-serviço. O boot() do service provider do plugin chama Billing::register(...) uma vez por fornecedor; o dropdown select-type voltado ao cliente, o picker de form admin e Billing::resolveService($gateway) todos leem desse 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
);

O callback CheckoutHandlerInterface

vendor/acelle/cashier/src/Contracts/CheckoutHandlerInterface.php declara os callbacks do lado do host que a camada de sync dispara quando o estado remoto diverge do estado de intent local. Plugins não chamam diretamente. O RemoteSubscriptionSyncService do host consome os métodos de leitura do plugin e despacha para CheckoutHandler::onPaymentSuccess / onSubscriptionCreated / onPaymentFailed / onPaymentRequiresAuth / onOfflineClaimReceived por si só.

A máquina de estado PaymentIntent

Cinco estados terminais ou pendentes; o plugin nunca transita estado de intent diretamente. A camada de sync do host lê estado de fornecedor pelo plugin e vira o intent pelo CheckoutHandler.

EstadoSignificado
PENDINGIntent criado, cliente ainda não pagou
REQUIRES_ACTIONDesafio 3DS / SCA esperando pelo cliente (só cartões)
AWAITING_ADMIN_APPROVALClaim offline pendente de revisão admin
SUCCEEDEDTerminal — pagamento confirmado
FAILED / CANCELLEDTerminal — aparece para o cliente com a razão do fornecedor

O pacote Cashier — oito gateways nativos para referência

O pacote Cashier forkado em /Users/luan/apps/cashier/src/Services/ entrega oito implementações de gateway nativas. Lê-las é o jeito mais rápido de ver a superfície completa que um plugin de gateway de pagamento pode querer implementar: StripePaymentGateway, StripeSubscriptionGateway, BraintreePaymentGateway, BraintreeSubscriptionGateway, PaypalPaymentGateway, PaystackPaymentGateway, RazorpayPaymentGateway, OfflinePaymentGateway. Os dois primeiros são em formato de assinatura; o resto é cartão one-off ou estilo transferência.

Quatro interfaces de capacidade

Todas as quatro vivem no pacote Cashier em /Users/luan/apps/cashier/src/Contracts/. A classe de gateway do plugin implementa só as interfaces que o fornecedor de fato suporta — o host faz checks instanceof em cada call-site.

InterfacePropósitoExigida para
IntentGatewayInterface Contrato base — getCheckoutUrl(intent, returnUrl) + getMethodTitle/getMethodInfo para display de método salvo Todo gateway
SupportsAutoChargeInterface autoCharge(intent, pmData) para cobrança de cartão off-session sem redirect Gateways com re-bill one-tap (Stripe one-off; Paddle não)
SupportsSubscriptionInterface createSubscription(intent, pmData) para criação headless de assinatura Gateways que suportam um fluxo de assinatura headless (Stripe Subscription); o createSubscription do Paddle lança porque o Paddle é hosted-checkout-only
RemoteSubscriptionGatewayInterface getRemotePlans / getRemoteSubscription / getRemoteSubscriptions / cancelRemoteSubscription / updateRemoteSubscriptionPlan / getRemotePaymentMethod — o lado read/sync Gateways onde o fornecedor é dono do estado de assinatura (Paddle, Stripe Subscription)

Scaffold do plugin

O layout completo de arquivos para um plugin de gateway de pagamento:

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)

Repare no que não está ali: sem controller de webhook, sem verificador de assinatura, sem tabela de proteção contra replay. Sync de estado é puxado pelo host, não empurrado pelo fornecedor.

ServiceProvider — uma única chamada Billing::register registra tudo

O service provider esqueleto completo para um plugin de gateway de pagamento (parafraseado de 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,
        ]));
    }
}

A closure dentro de Billing::register lê credenciais da coluna JSON gatewayData do PaymentGateway via getGatewayData('key'). É assim que os campos de form do admin fluem para o constructor do service.

Serviço do gateway — getCheckoutUrl retorna a URL do plugin

O host chama getCheckoutUrl no momento em que o cliente se compromete com um checkout. A implementação precisa ser barata e sem efeitos colaterais — sem chamadas de API de fornecedor durante o render:

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

A URL aponta para o próprio controller do plugin, não para a URL do fornecedor diretamente. Três razões:

  • A chamada de API do fornecedor para criar o hosted-checkout real (Paddle: POST /transactions) precisa viver atrás de uma fronteira de controller para que erros possam ser pegos e o cliente possa ser redirecionado de volta para a página de fatura com um flash de erro.
  • Logging e throttling pertencem ao controller, não ao gateway service.
  • O gateway service permanece "puro" — sem efeitos colaterais HTTP quando getCheckoutUrl roda em tempo de intent-create. getCheckoutUrl pode ser chamado especulativamente, incluindo em testes.

Controller de checkout — chama fornecedor + 302 para hosted checkout

O CheckoutController::redirect() do plugin é onde a chamada de API do fornecedor de fato acontece. O cliente bate em /cashier/paddle/checkout/{uid}, o controller chama o endpoint POST /transactions do Paddle e dá 302 para a URL de hosted checkout que o Paddle retornou:

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 é um método interno do plugin no PaddleGateway (não em nenhuma interface). Envelopa o POST /transactions do fornecedor com a forma JSON específica do Paddle.

Crítico: custom_data.intent_uid é enviado ao fornecedor e ecoado de volta em leituras subsequentes — GET /subscriptions/{id} retorna o mesmo custom_data. É assim que a camada de sync mapeia uma assinatura Paddle de volta a um PaymentIntent local. Perca isso e o sync falha silenciosamente.

Mappers do lado leitura — alimentando a camada de sync

O plugin expõe o estado do fornecedor pelos métodos da RemoteSubscriptionGatewayInterface. O RemoteSubscriptionSyncService do host (cron + on-demand) chama essas para atualizar DTOs locais, depois despacha para CheckoutHandler quando o estado 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,
    ];
}

Contrato de paginação: retorne {data, has_more, next_cursor}. O service de sync caminha pelas páginas até has_more => false. Mappers DTO (priceToDto, subscriptionToDto) traduzem a forma JSON do fornecedor para a forma DTO neutra do host — esse é o conhecimento por-fornecedor mais difícil de compartilhar, porque a forma de resposta de cada fornecedor difere.

Matriz de capacidade — quatro padrões de fornecedor

As interfaces que um plugin implementa dependem do modelo de pagamento do fornecedor. Os quatro padrões que o host já suporta:

Padrão de fornecedorImplementaExemplos
Cobrança one-off com token de cartão IntentGatewayInterface + SupportsAutoChargeInterface Stripe one-off, Square, Razorpay one-off
Assinatura hosted-checkout IntentGatewayInterface + RemoteSubscriptionGatewayInterface + SupportsSubscriptionInterface (createSubscription lança) Paddle, Lemon Squeezy
Assinatura headless IntentGatewayInterface + SupportsSubscriptionInterface + RemoteSubscriptionGatewayInterface Stripe Subscription, Braintree Subscription
Manual / transferência bancária IntentGatewayInterface Offline (transferência bancária, dinheiro)

Não implemente interfaces não usadas. Billing::supportsRemoteSubscription($gw) lê a flag isRemoteSubscription setada no momento de registro, não via instanceof — mas a flag precisa bater com o que o seu service de fato faz, então mantenha alinhado.

Testando o contrato de capacidade

Sem código de webhook, não há fronteira de segurança para unit-test. Foque no contrato de capacidade — as coisas das quais callers dependem como garantias da forma do gateway.

Coisas para unit-testar

  • createSubscription lança para fornecedores hosted-checkout-only (Paddle) — callers sabem usar getCheckoutUrl em vez.
  • getCheckoutUrl retorna uma rota para o controller do seu plugin, não uma URL de fornecedor — prova que a fronteira de controller está no lugar.
  • getRemoteSubscription retorna um RemoteSubscriptionDTO populado quando o fornecedor retorna uma forma conhecida — exercite subscriptionToDto com uma fixture gravada.
  • Contrato de paginação — getRemoteSubscriptions com múltiplas páginas caminha até has_more => false e retorna data concatenada.

Coisas para não unit-testar

  • Respostas de API de fornecedor ao vivo — essas precisam de uma conta sandbox real e estão fora do escopo de unit test. Verifique ao vivo antes de entregar (curl os endpoints com uma chave sandbox).
  • Mappers DTO quando são privados e fortemente acoplados à forma JSON do fornecedor — cubra indiretamente por testes end-to-end com fixtures gravadas, ou exponha para teste apenas quando a lógica de mapeamento for não-trivial.
  • Roteamento — o Laravel cobre loadRoutesFrom; o nome da rota resolve em runtime enquanto a closure capturar.
  • Caminho feliz de Billing::register — o próprio BillingManagerTest do host cobre.

Registre a testsuite do plugin no phpunit.xml do host conforme o deep-dive de Testes: <testsuite name="Plugin: acelle/paddle"> ... </testsuite>. Rode a suite com ./vendor/bin/pest --testsuite="Plugin: acelle/paddle".

Ciclo de vida de ativação

EventoO que acontece
php artisan plugin:init author/nameArquivos gerados em storage/app/plugins/.... Linha do banco inserida com status=inactive. Service provider auto-carregado.
Admin clica em AtivarDispara activate_plugin_author/name → hook do plugin roda migrations. Status do banco vira active. Gateway agora visível no dropdown select-type.
Admin clica em DesativarStatus do banco vira inactive. Service provider continua carregado. Rotas ainda resolvem. O tipo de gateway ainda está no BillingManager até o próximo boot de processo.
Admin clica em DeletarDispara delete_plugin_author/name → hook do plugin faz rollback das migrations. Arquivos removidos, linha do banco deletada, entrada do master file limpa.

Proteção para semânticas de desativação: se "desativar = gateway some imediatamente" importa para sua instalação, cheque Plugin::getByName('myvendor/myplugin')->isActive() dentro da closure factory de service do Billing::register e lance se não estiver. O plugin embutido acelle/paddle atualmente não faz isso — o gateway continua disponível mesmo quando um admin desativa (até o próximo restart de processo). Hardening futuro; por ora o padrão vive em Arquitetura de plugins § Por que plugins inativos ainda afetam o app.

Disciplina de fronteira de fornecedor

Esses são os padrões que teste só-de-estado-local perde. Cada um veio de um bug real que entregou antes de ser pego por um humano-olhando-na-tela. Leia esta seção antes de escrever o gateway service.

1. Passe campos com unidade explicitamente — nunca dependa de defaults do fornecedor

Amount sozinho não é suficiente. Fornecedores interpretam amount como unidades menores de alguma moeda; se o plugin não passar Currency, o fornecedor cai no default do terminal ou da conta. O plugin TBANK (referência irmã) originalmente omitia Currency do seu payload Init — default do terminal era RUB, plano era USD, cliente via "₽49" enquanto o merchant acreditava ter cobrado "$49". O bug era invisível para assertions de banco local porque o intent local dutifully gravava currency='USD'; só o display do fornecedor contava a verdade.

// ❌ 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. Mapeie custom_data de volta por cada endpoint de leitura

O plugin envia custom_data.intent_uid no checkout create. O fornecedor ecoa de volta em toda leitura relacionada — detalhe de assinatura, detalhe de transação, detalhe de método de pagamento. O plugin precisa ler de cada forma porque a localização de custom_data do fornecedor pode diferir entre endpoints de leitura. Perca o mapeamento em qualquer um e o sync silenciosamente deixa aquele intent em PENDING para sempre.

3. Nunca lance uma exceção não tratada de dentro de getCheckoutUrl

getCheckoutUrl é chamado durante render de página. Um throw não tratado produz uma página 500 onde o cliente esperava um botão de checkout. Mantenha o método sem efeitos colaterais; deixe o controller tratar falhas de chamada do fornecedor e exibir o erro ao flash do cliente.

4. Pegue toda chamada de fornecedor no controller e faça flash do erro

Clientes precisam ver o que o fornecedor disse para poderem escolher um gateway diferente ou contatar suporte. Uma página 500 não diz nada. O try / catch + redirect()->away($returnUrl)->with('alert-error', ...) do controller Paddle é o padrão canônico.

5. Logue toda chamada de fornecedor com o UID do intent

Sem o UID do intent na linha de log, debugar uma falha reportada por cliente significa scrollar centenas de linhas de log não relacionadas. \Log::error('Paddle checkout: createTransaction failed', ['intent_uid' => $intent->uid, 'error' => $e->getMessage()]) é a forma útil mínima; times rodando múltiplos gateways adicionam uma tag 'gateway' => 'paddle' também.

Para onde ir em seguida

Drivers de envio (modelo push com webhooks) e gateways de pagamento (modelo pull com leituras) são as duas páginas mais pesadas de exemplo trabalhado. Juntos cobrem os dois lados do espectro de fronteira de fornecedor que o host suporta hoje. A próxima página — a vitrine acelle/ai — percorre o plugin complexo canônico de ponta a ponta como exercício de leitura-compreensão: oito models Eloquent, quatorze migrations, dezoito locales, a superfície UI do chatbox, cada padrão de hook em produção. Use como codebase de referência quando estiver pronto para construir algo maior que um driver ou um gateway.

Quando o gateway estiver entregue e ao vivo, rode o ciclo activate → test → delete do deep-dive de Testes — pega uma classe de bugs de hook delete_plugin_* que unit tests não pegam. Para as referências cross-page mais amplas, a visão geral de Arquitetura de plugins tem o mapa de ciclo de vida completo.