split-payment-laravel maintained by ricardotulio
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 é)
- 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 --> [*]
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.