ricardotulio / split-payment-laravel
Camada reutilizável e agnóstica para preparar aplicações Laravel/PHP para o Split Payment (IBS/CBS) da Reforma Tributária brasileira.
Package info
github.com/RicardoAugustoTulio/split-payment-laravel
pkg:composer/ricardotulio/split-payment-laravel
Requires
- php: ^8.2
- illuminate/database: ^10.0 || ^11.0 || ^12.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0
Requires (Dev)
- larastan/larastan: ^2.9
- laravel/pint: ^1.15
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^10.0 || ^11.0
Suggests
- web-token/jwt-framework: Necessário apenas para o módulo Public Platform (assinatura JWS RS256/detached) — uso PSP-only (milestone M4).
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-21 14:58:56 UTC
README
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 é)
- Modelo mental em 30 segundos
- Requisitos
- Instalação
- Configuração
- Quickstart (5 minutos)
- Conceitos essenciais
- Cookbook (receitas)
- Máquina de estados
- Tabelas de referência
- Idempotência, retries e concorrência
- Eventos
- Multi-tenant
- Escrevendo seu próprio PSP driver
- Testes
- Roadmap
- Contribuindo
- Licença e fontes
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) ePXE,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/IBS —
Informado(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 + codMsgiguais) → ignorado (nenhuma linha nova). - Fora de ordem (
nsuIdmenor que o último aplicado) → registrado como evento e não regride o estado. - Órfão (correlação sem transação) → guardado em
split_eventspara 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 --> [*]
Loading
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
PspManagerestende oManagerdo Laravel. Para drivers que exigem construção customizada, você pode estender/rebindar oPspManagere adicionar um métodocreateMeupspDriver().
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.