Looking to hire Laravel developers? Try LaraJobs

split-payment-laravel maintained by ricardotulio

Description
Camada reutilizável e agnóstica para preparar aplicações Laravel/PHP para o Split Payment (IBS/CBS) da Reforma Tributária brasileira.
Last update
2026/09/21 16:57 (dev-main)
License
Links
Downloads
0

Comments
comments powered by Disqus

split-payment-laravel

Camada Laravel/PHP para preparar sua aplicação (e-commerce/ERP) para o Split Payment (IBS/CBS) da Reforma Tributária brasileira: modelagem de transações, correlação pagamento ↔ documento fiscal, abstração de PSP, ingestão de conciliação idempotente e eventos de estorno — sem acoplar ao seu gateway nem ao seu domínio de pedidos.

ricardotulio/split-payment-laravel


Índice


O que este pacote é (e o que não é)

No Split Payment, quem conversa com a Plataforma Pública (o hub do governo) é o PSP / instituição operadora — não a loja. A loja/ERP tem outra responsabilidade: preparar os campos fiscais, manter os identificadores de correlação e conciliar o que volta. Este pacote implementa exatamente essa fatia.

✅ Este pacote faz:

  • Monta e valida os campos fiscais do split (CBS Informado, IBS Informado e docFiscal).
  • Persiste a transação de split e seus identificadores de correlação (pagamento ↔ documento fiscal).
  • Oferece um contrato de PSP para anexar os campos fiscais à cobrança e normalizar o retorno.
  • Ingere conciliação de forma idempotente (à prova de webhook repetido e evento fora de ordem).
  • Trata estorno como evento de negócio e expõe eventos Laravel para você reagir.

🚫 Este pacote NÃO faz (de propósito):

  • Não chama a Plataforma Pública (informes, segregação, Super Inteligente, MOC) — isso é do PSP.
  • Não calcula CBS/IBS nem faz cálculo de segregação/proporcionalidade — isso é do emissor fiscal e do PSP.
  • Não substitui seu gateway de pagamento — ele enriquece o fluxo com dados de split.
  • Não cobre cartões/RAD — fora do escopo da Fase 1 oficial (roadmap futuro).

A análise técnica completa (fontes oficiais, contrato OpenAPI v1.1.0, responsabilidades) está em docs/split-payment-analysis.md.


Modelo mental em 30 segundos

Venda → NF-e/NFC-e (CBS/IBS + chave)         ┌── você usa ESTE pacote aqui ──┐
                    │                         │                              │
   você informa CBS/IBS + docFiscal ──► [ split-payment-laravel ] ──► seu PSP ──► Plataforma Pública ──► RFB/CGIBS
                    │                         │  (correlação + idempotência) │
   PSP devolve conciliação (webhook) ◄────────┘  você ingere e concilia  ◄───┘

Você fala com o pacote e com o seu PSP. O PSP fala com o governo.


Requisitos

  • PHP 8.2+
  • Laravel 10, 11 ou 12 (illuminate/support, illuminate/database)
  • Um banco relacional (MySQL/PostgreSQL/SQLite)

Instalação

composer require ricardotulio/split-payment-laravel

O SplitPaymentServiceProvider é registrado automaticamente (package discovery). Em seguida, publique a configuração e as migrations e rode as migrations:

php artisan vendor:publish --tag=split-payment-config
php artisan vendor:publish --tag=split-payment-migrations
php artisan migrate

Isso cria as tabelas split_transactions, split_correlations, split_events, split_reconciliations, split_refunds e split_idempotency_keys.

Nota: o provider não registra rotas nem observers automaticamente — nada roda "por baixo dos panos". As migrations são publicáveis (não carregadas automaticamente) para você manter controle do seu schema.


Configuração

config/split-payment.php (resumo):

return [
    'psp' => [
        'default' => env('SPLIT_PAYMENT_PSP', 'fake'), // driver padrão
        'drivers' => [
            'fake' => ['adapter' => \RicardoTulio\SplitPayment\Psp\Drivers\Fake\FakeProvider::class],
            // 'meupsp' => ['adapter' => \App\Split\MeuPspProvider::class],
        ],
    ],

    // Integração direta com a Plataforma Pública fica DESLIGADA (é responsabilidade do PSP).
    'public_platform' => [
        'enabled' => (bool) env('SPLIT_PAYMENT_PUBLIC_PLATFORM', false),
        'spec'    => 'v1.1.0',
    ],

    'idempotency' => [
        'store'  => env('SPLIT_PAYMENT_IDEMPOTENCY_STORE', 'database'),
        'scope'  => env('SPLIT_PAYMENT_IDEMPOTENCY_SCOPE', 'default'),
        'tenant' => env('SPLIT_PAYMENT_TENANT'),          // multi-tenant (opcional)
        'ttl'    => (int) env('SPLIT_PAYMENT_IDEMPOTENCY_TTL', 86400),
    ],
];

.env típico:

SPLIT_PAYMENT_PSP=fake
SPLIT_PAYMENT_IDEMPOTENCY_TTL=86400

Quickstart (5 minutos)

O ponto de entrada é a fachada SplitPaymentManager, resolvível pelo container (app(...) ou app('split-payment')).

use RicardoTulio\SplitPayment\Services\SplitPaymentManager;
use RicardoTulio\SplitPayment\Dtos\FiscalContext;
use RicardoTulio\SplitPayment\Dtos\RegisterTransactionCommand;
use RicardoTulio\SplitPayment\Enums\Arranjo;
use RicardoTulio\SplitPayment\Enums\CorrelationType;

$split = app(SplitPaymentManager::class);

// 1) Prepare e valide os campos fiscais (CBS/IBS Informado + docFiscal).
$fiscal = $split->prepareFiscalFields(new FiscalContext(
    valorOriginal: '1000.00',
    cbs:           '88.00',
    ibs:           '92.00',
    docFiscal:     '35260812345678000190550010000000151000000015', // chave da NF-e
));

// 2) Registre a transação de split (nasce no estado "iniciada").
$tx = $split->registerTransaction(new RegisterTransactionCommand(
    tenantId:     'loja-1',
    arranjo:      Arranjo::PXD,          // Pix Dinâmico (iniciado pelo Recebedor)
    fiscal:       $fiscal,
    cnpjRec:      '12345678000190',
    cnpjPagOrig:  '98765432000121',
    pspDriver:    'fake',
    valorOriginal:'1000.00',
    pspTransactionId: 'PSP-TX-123',      // id da transação no seu PSP
    correlations: [
        ['type' => CorrelationType::TxId, 'value' => 'TX-abc-123'], // TxID do Pix
    ],
));

// 3) Recupere quando precisar (por id).
$mesma = $split->transaction($tx->id);

O docFiscal e o pspTransactionId já são correlacionados automaticamente — você não precisa vinculá-los à mão.


Conceitos essenciais

  • Arranjo — o meio pelo qual a cobrança nasce: BOL, PXD, PXA (iniciados pelo Recebedor, modelo Super Inteligente) e PXE, TED, TEF (iniciados pelo Pagador, modelo Inteligente).
  • docFiscal — a chave de acesso do documento fiscal. Opcional nos arranjos do Recebedor, obrigatório nos do Pagador (o pacote valida isso pra você).
  • Categorias de valor CBS/IBSInformado (você declara) → Corrigido/Em Aberto (governo, via PSP) → Segregado (recolhido) → Aplicado (exibição). Você informa e concilia; não calcula o segregado.
  • Correlação — identificadores que precisam sobreviver ao fluxo (docFiscal, txId, idDda, nsuId, resourceId…). O pacote persiste e recupera por qualquer um deles.
  • Idempotência — conciliação repetida ou fora de ordem nunca produz efeito duplicado.

Cookbook (receitas)

1. Validar campos fiscais antes de cobrar

prepareFiscalFields aplica as regras oficiais: cbs ≥ 0, ibs ≥ 0, cbs + ibs ≤ valorOriginal, CBS/IBS em conjunto, 2 casas decimais.

use RicardoTulio\SplitPayment\Dtos\FiscalContext;
use RicardoTulio\SplitPayment\Exceptions\FiscalValidationException;

try {
    $fiscal = $split->prepareFiscalFields(new FiscalContext(
        valorOriginal: '100.00',
        cbs: '60.00',
        ibs: '50.00', // 60 + 50 > 100  →  inválido
    ));
} catch (FiscalValidationException $e) {
    // "cbs + ibs cannot exceed valorOriginal."
}

Split com valores zero é permitido (você opta por não recolher, mas os campos existem):

$fiscal = $split->prepareFiscalFields(new FiscalContext('0.00', '0.00', '0.00'));

2. Registrar transação — arranjo iniciado pelo Recebedor (Boleto / Pix)

use RicardoTulio\SplitPayment\Enums\Arranjo;
use RicardoTulio\SplitPayment\Enums\CorrelationType;

$tx = $split->registerTransaction(new RegisterTransactionCommand(
    tenantId: 'loja-1',
    arranjo: Arranjo::BOL,                 // Boleto
    fiscal: $fiscal,                       // docFiscal opcional aqui
    cnpjRec: '12345678000190',
    cnpjPagOrig: '98765432000121',
    pspDriver: 'fake',
    valorOriginal: '1000.00',
    pspTransactionId: 'PSP-TX-1',
    correlations: [
        ['type' => CorrelationType::IdDda,       'value' => 'DDA-0001'],
        ['type' => CorrelationType::NumCtrlOrig, 'value' => 'NUC-0001'],
    ],
));

echo $tx->estado->value;  // "iniciada"
echo $tx->modelo->value;  // "super_inteligente" (derivado do arranjo)

3. Registrar transação — arranjo iniciado pelo Pagador (Pix Estático / TED / TEF)

Nesses arranjos o docFiscal é obrigatório — o pacote rejeita se faltar:

use RicardoTulio\SplitPayment\Enums\Arranjo;

$fiscal = $split->prepareFiscalFields(new FiscalContext(
    valorOriginal: '500.00', cbs: '44.00', ibs: '46.00',
    docFiscal: '35260812345678000190550010000000151000000015', // obrigatório
));

$tx = $split->registerTransaction(new RegisterTransactionCommand(
    tenantId: 'loja-1',
    arranjo: Arranjo::TED,
    fiscal: $fiscal,
    cnpjRec: '12345678000190',
    cnpjPagOrig: '98765432000121',
    pspDriver: 'fake',
    valorOriginal: '500.00',
));
// Sem docFiscal → FiscalValidationException ("Pagador-initiated requires a docFiscal").

4. Anexar os dados de split à cobrança via PSP

O PSP driver injeta CBS/IBS/docFiscal no seu PaymentIntent antes de você criar a cobrança no PSP.

use RicardoTulio\SplitPayment\Psp\PspManager;
use RicardoTulio\SplitPayment\Psp\DTOs\PaymentIntent;

$provider = app(PspManager::class)->driver();      // driver padrão (config)
// ou: app(PspManager::class)->driver('meupsp');

$intent = $provider->attachSplitData(
    new PaymentIntent(['amount' => '1000.00']),
    $fiscal,
    $fiscal->docFiscal,
);

$intent->get('split'); // ['cbs' => '88.00', 'ibs' => '92.00', 'docFiscal' => '...']
// Agora envie $intent->attributes() para o seu PSP ao criar o boleto/QR.

5. Ingerir conciliação do PSP (webhook) de forma idempotente

Quando o PSP devolve os valores conciliados, normalize e ingira. Repetição e fora-de-ordem são tratados.

use RicardoTulio\SplitPayment\Psp\PspManager;

// Dentro do controller que recebe o webhook do SEU PSP:
public function handle(\Illuminate\Http\Request $request, SplitPaymentManager $split)
{
    $provider = app(PspManager::class)->driver();

    // Traduz o payload do PSP para o formato normalizado do pacote.
    $entry = $provider->normalizeReconciliation($request->all());

    $split->ingestReconciliation($entry); // idempotente

    return response()->noContent();
}

O payload normalizado carrega o tipo e valor de correlação (para localizar a transação), os valores por categoria, o codMsg e o nsuId (ordenação). Exemplo de payload:

$entry = $provider->normalizeReconciliation([
    'correlationType' => 'doc_fiscal',   // valor do enum CorrelationType
    'correlationValue' => '3526...0015',
    'cbsSegregado' => '88.00',
    'ibsSegregado' => '92.00',
    'codMsg' => 'RSUP201',
    'nsuId' => '1024',
]);

$split->ingestReconciliation($entry);
$split->ingestReconciliation($entry); // 2ª vez: no-op (idempotente)

Comportamento garantido:

  • Duplicado (transação + nsuId + codMsg iguais) → ignorado (nenhuma linha nova).
  • Fora de ordem (nsuId menor que o último aplicado) → registrado como evento e não regride o estado.
  • Órfão (correlação sem transação) → guardado em split_events para reprocessamento, sem falhar.

6. Solicitar estorno (evento de negócio)

O estorno da loja é um evento de negócio (a chamada MOC ao governo é do PSP). Só é permitido em transações que já podem ser estornadas (segregada, repassada ou em_analise).

use RicardoTulio\SplitPayment\Dtos\RefundRequest;
use RicardoTulio\SplitPayment\Enums\CodMotOcor;

$refund = $split->requestRefund(new RefundRequest(
    splitTransactionId: $tx->id,
    valor: '1000.00',
    cbsEst: '88.00',
    ibsEst: '92.00',
    codMotOcor: CodMotOcor::FalhaOperacional->value, // '01' incidente | '02' falha operacional
    cnpjCpfDest: '98765432000121',                    // parte prejudicada
));

echo $refund->status; // "requested"

7. Recuperar transação por identificador de correlação

use RicardoTulio\SplitPayment\Services\CorrelationRegistry;
use RicardoTulio\SplitPayment\Enums\CorrelationType;

$registry = app(CorrelationRegistry::class);

$tx = $registry->findTransaction(CorrelationType::DocFiscal, '3526...0015');
$tx = $registry->findTransaction(CorrelationType::TxId, 'TX-abc-123', 'loja-1'); // por tenant

8. Reagir a eventos

use Illuminate\Support\Facades\Event;
use RicardoTulio\SplitPayment\Events\SplitTransactionRegistered;
use RicardoTulio\SplitPayment\Events\ReconciliationReceived;
use RicardoTulio\SplitPayment\Events\RefundRequested;

Event::listen(ReconciliationReceived::class, function (ReconciliationReceived $e) {
    $recon = $e->reconciliation; // atualize seu extrato/conciliação interna
});

Máquina de estados

O estado da transação evolui de forma controlada — transições inválidas lançam InvalidTransitionException.

stateDiagram-v2
    [*] --> iniciada
    iniciada --> atualizada
    iniciada --> paga
    iniciada --> baixada
    iniciada --> em_analise
    atualizada --> paga
    atualizada --> baixada
    atualizada --> em_analise
    paga --> segregada
    paga --> em_analise
    segregada --> repassada
    segregada --> estornada
    segregada --> em_analise
    repassada --> estornada
    em_analise --> paga
    em_analise --> segregada
    em_analise --> baixada
    em_analise --> estornada
    baixada --> [*]
    repassada --> [*]
    estornada --> [*]

Tabelas de referência

Arranjos (RicardoTulio\SplitPayment\Enums\Arranjo)

Caso Código Iniciado por Modelo docFiscal
BOL Boleto Recebedor Super Inteligente opcional
PXD Pix Dinâmico Recebedor Super Inteligente opcional
PXA Pix Automático Recebedor Super Inteligente opcional
PXE Pix Estático Pagador Inteligente obrigatório
TED TED Pagador Inteligente obrigatório
TEF TEF Pagador Inteligente obrigatório

Tipos de correlação (CorrelationType): DocFiscal, TxId, IdDda, NumCtrlOrig, IdRepasse, IdInfSegr, IdLote, NsuId, IdOcor, ResourceId, PspTransaction.

Motivo de ocorrência do estorno (CodMotOcor): IncidenteSeguranca = '01', FalhaOperacional = '02'.

Categorias de valor (TaxCategory): Informado, Corrigido, EmAberto, Segregado, Aplicado.


Idempotência, retries e concorrência

A ingestão de conciliação é embrulhada por um IdempotencyStore (lock atômico + dedupe em split_idempotency_keys) e por uma unique constraint em (split_transaction_id, nsu_id, cod_msg). Na prática:

  • Reprocessar o mesmo webhook não duplica efeito.
  • Dois workers processando o mesmo evento ao mesmo tempo resultam em um efeito.
  • Eventos fora de ordem (por nsuId) não regridem o estado.

Você pode ajustar a janela e o escopo em config/split-payment.php (idempotency.ttl, idempotency.scope).


Eventos

Evento Quando Payload
SplitTransactionRegistered após registrar uma transação ->transaction
ReconciliationReceived após aplicar uma conciliação ->reconciliation
RefundRequested após solicitar um estorno ->refund

Multi-tenant

Todas as tabelas têm tenant_id. Basta passar tenantId no RegisterTransactionCommand e (opcionalmente) o tenant nas buscas de correlação. Um mesmo docFiscal/txId pode coexistir em tenants diferentes sem colisão.

$registry->findTransaction(CorrelationType::DocFiscal, '3526...0015', tenantId: 'loja-1');

Escrevendo seu próprio PSP driver

Implemente o contrato SplitAwarePaymentProvider e registre no config.

use RicardoTulio\SplitPayment\Psp\Contracts\SplitAwarePaymentProvider;
use RicardoTulio\SplitPayment\Psp\DTOs\PaymentIntent;
use RicardoTulio\SplitPayment\Dtos\ReconciliationEntry;
use RicardoTulio\SplitPayment\Dtos\RefundRequest;
use RicardoTulio\SplitPayment\Models\SplitTransaction;
use RicardoTulio\SplitPayment\ValueObjects\{FiscalAmounts, DocumentoFiscalRef};

final class MeuPspProvider implements SplitAwarePaymentProvider
{
    public function driver(): string { return 'meupsp'; }

    public function attachSplitData(PaymentIntent $intent, FiscalAmounts $fiscal, ?DocumentoFiscalRef $df): PaymentIntent
    {
        return $intent->withSplitData([
            'cbs' => $fiscal->cbs->amount->toDecimalString(),
            'ibs' => $fiscal->ibs->amount->toDecimalString(),
            'docFiscal' => $df?->value() ?? $fiscal->docFiscal?->value(),
        ]);
    }

    public function normalizeReconciliation(array $pspPayload): ReconciliationEntry
    {
        // Traduza o formato do SEU PSP para o formato normalizado:
        return ReconciliationEntry::fromArray([
            'correlationType'  => 'doc_fiscal',
            'correlationValue' => $pspPayload['nota'],
            'cbsSegregado'     => $pspPayload['cbs_retido'] ?? null,
            'ibsSegregado'     => $pspPayload['ibs_retido'] ?? null,
            'codMsg'           => $pspPayload['evento'],
            'nsuId'            => (string) $pspPayload['sequencia'],
        ]);
    }

    public function requestRefund(SplitTransaction $tx, RefundRequest $req): array
    {
        // Chame a API do seu PSP e retorne o resultado bruto.
        return ['status' => 'accepted'];
    }
}

Registre em config/split-payment.php:

'psp' => [
    'default' => 'meupsp',
    'drivers' => [
        'meupsp' => ['adapter' => \App\Split\MeuPspProvider::class],
    ],
],

O PspManager estende o Manager do Laravel. Para drivers que exigem construção customizada, você pode estender/rebindar o PspManager e adicionar um método createMeupspDriver().


Testes

Com o pacote instalado no seu projeto, os testes do próprio pacote rodam via PHPUnit:

composer install
vendor/bin/phpunit          # suíte completa
vendor/bin/phpunit --testsuite unit
vendor/bin/pint --test      # estilo
vendor/bin/phpstan analyse  # análise estática (level 6)

A suíte cobre value objects, máquina de estados, persistência/correlação, idempotência, ingestão de conciliação (duplicidade e fora de ordem), concorrência e um teste de ponta a ponta com o FakeProvider.


Roadmap

Marco Conteúdo Status
M1 — Split Core domínio, persistência, correlação, PSP (fake), ingestão idempotente, estorno ✅ implementado
M2 — Integrações reais drivers de PSP reais, integração fiscal (NF-e), webhooks planejado
M3 — Observabilidade/Qualidade correlation id, métricas, contract tests contra o OpenAPI planejado
M4 — Plataforma Pública (PSP-only) cliente da PP + JWS/JWKS bloqueado (aguarda Manual de Segurança)

Detalhes em docs/split-payment-analysis.md e nas specs em .specs/.


Contribuindo

Contribuições são bem-vindas! Abra uma issue para discutir ideias/bugs ou envie um pull request. Antes de enviar, rode o gate local: vendor/bin/pint, vendor/bin/phpstan analyse e vendor/bin/phpunit devem passar.


Licença e fontes

Licença MIT.

As regras implementadas seguem as fontes oficiais publicadas em https://cgibs.gov.br/split-payment (OpenAPI v1.1.0, Manual de Integração v1.1.0, Manuais de Operações e de Tempos). Trechos marcados como minuta/indefinidos são tratados como fora do escopo até estabilização — ver docs/split-payment-analysis.md.

Aviso: este pacote é uma ferramenta de integração e não constitui orientação fiscal/jurídica. Valide o enquadramento tributário da sua operação com seu contador/assessoria.