Uma venda parcelada no cartão não é um recebível: são N recebíveis, cada um com valor, taxa, data prevista e status próprios. Para uma maquininha ou conta PJ, a forma que funciona é gerar essas parcelas no momento da captura, com a mesma regra de arredondamento e o mesmo calendário do adquirente. Depois, cada parcela é conciliada contra a agenda que ele publica. Se você guarda só a venda e calcula o resto na hora de mostrar o extrato, a conta não fecha quando aparecer o primeiro estorno parcial ou a primeira antecipação.
Na Granpag esse é um dos pontos em que mais aparece a diferença entre "funciona na demo" e "funciona no fechamento do mês". Abaixo estão as decisões de modelagem que eu tomo hoje, com o código em Laravel e os trade-offs de cada uma.
O que é agenda de recebíveis e por que não basta guardar a venda?
Agenda de recebíveis é a lista de valores que o lojista tem a receber do adquirente, com a data em que cada valor deve ser liquidado. Adquirente é quem processa a transação de cartão e paga o lojista. O subadquirente faz a mesma coisa como intermediário de um adquirente. Liquidação é o dinheiro efetivamente caindo na conta.
Uma venda de R$ 1.000 em 10x no crédito vira dez recebíveis de cerca de R$ 100, menos a taxa, com vencimentos em D+30, D+60 até D+300. D+N é a convenção para "N dias depois da data da venda". Esses recebíveis têm vida própria:
- A parcela 3 pode ser antecipada e as outras não.
- Um chargeback pode reter a parcela 7 em diante.
- Um estorno parcial pode ser descontado das próximas parcelas, ou até de outra venda.
- A data da parcela 5 pode cair num feriado e ser empurrada para o dia útil seguinte.
Se o modelo tem só sales.amount e sales.installments, cada uma dessas situações vira um cálculo derivado espalhado pelo código. Cada cálculo derivado é uma chance de o extrato mostrar um valor e o banco pagar outro. A regra que sigo é simples: o recebível é uma linha no banco, criada na captura e alterada só por transições explícitas.
Como modelar a tabela de recebíveis no Laravel?
A migration abaixo é o núcleo. Todo valor monetário é inteiro em centavos.
Schema::create('receivables', function (Blueprint $table) { $table->id(); $table->foreignId('sale_id')->constrained(); $table->foreignId('merchant_id')->constrained(); $table->unsignedSmallInteger('installment_number'); $table->unsignedSmallInteger('installments_total'); $table->bigInteger('gross_cents'); $table->bigInteger('fee_cents'); $table->bigInteger('net_cents'); $table->date('expected_date'); $table->date('settled_date')->nullable(); $table->string('status', 20); // scheduled, settled, anticipated, held, cancelled $table->foreignId('anticipation_id')->nullable(); $table->string('acquirer_reference')->nullable(); // NSU + parcela, ou id da agenda $table->timestamps(); $table->unique(['sale_id', 'installment_number']); $table->index(['merchant_id', 'expected_date', 'status']); });
Decisões que valem explicar:
gross,feeenetgravados, não calculados. Taxas mudam e contratos mudam. O recebível precisa refletir a taxa vigente no momento da venda, não a de hoje.expected_dateseparado desettled_date. A diferença entre os dois campos é o que a conciliação vai olhar.- Índice por
merchant_id, expected_date, status. É a consulta da tela "quanto vou receber nos próximos dias" e a da antecipação. Se a tabela crescer muito, ela é candidata natural a particionamento por data. - Unique em
sale_id, installment_number. Se o job que gera as parcelas rodar duas vezes, o banco recusa a segunda inserção.
Uma coisa que não guardo aqui é o ajuste de estorno. Estorno vira outra linha, como mostro mais abaixo.
Como dividir o valor da venda entre as parcelas sem perder centavo?
R$ 100,00 em 3x não divide exato. Alguém fica com o centavo a mais. Já escrevi sobre centavos em inteiro e split sem perder centavo no Laravel. Aqui o detalhe é outro: a regra de quem fica com o resto precisa ser a mesma do adquirente. A convenção mais comum é jogar a diferença na primeira parcela, mas há adquirentes que jogam na última. Se a sua regra for diferente da dele, o total da venda bate e cada parcela diverge por um centavo. A conciliação por parcela passa a acusar erro em metade das vendas.
A mesma coisa vale para a taxa. MDR (Merchant Discount Rate) é o percentual que o adquirente desconta de cada venda. Em parcelado, ele costuma variar por faixa de parcelas. Você pode calcular a taxa sobre o total e dividir, ou calcular parcela por parcela, e os dois caminhos arredondam diferente. Mais uma vez, a resposta certa é a do contrato com o adquirente.
final class InstallmentPlan { /** @return list<array{number:int, gross:int, fee:int, net:int}> */ public static function build(int $grossCents, int $installments, int $mdrBasisPoints): array { $base = intdiv($grossCents, $installments); $remainder = $grossCents - $base * $installments; // Taxa calculada sobre o total, arredondando meio centavo para cima $totalFee = intdiv($grossCents * $mdrBasisPoints + 5000, 10000); $feeBase = intdiv($totalFee, $installments); $feeRemainder = $totalFee - $feeBase * $installments; $plan = []; for ($n = 1; $n <= $installments; $n++) { $gross = $base + ($n === 1 ? $remainder : 0); $fee = $feeBase + ($n === 1 ? $feeRemainder : 0); $plan[] = ['number' => $n, 'gross' => $gross, 'fee' => $fee, 'net' => $gross - $fee]; } return $plan; } }
O mdrBasisPoints vem de uma tabela de tarifas versionada por lojista e por faixa de parcelas, com data de vigência. Nunca vem de uma constante. Um teste que vale ter: para qualquer combinação de valor e parcelas, a soma dos gross precisa ser igual ao bruto e a soma dos fee igual à taxa total. Com teste de propriedade isso roda em milhares de combinações em poucos segundos.
Como calcular a data prevista de cada parcela: D+30, dias úteis e feriados?
Os prazos típicos por produto são estes. O contrato define os valores exatos:
| Produto | Prazo típico | Observação |
|---|---|---|
| Débito | D+1 | Dia útil seguinte |
| Crédito à vista | D+30 | Alguns planos oferecem D+1 com taxa maior |
| Crédito parcelado | D+30 por parcela | Parcela n em D+30×n |
| Pix | D+0 | Liquidação imediata, fora da agenda de cartão |
Três armadilhas aparecem em quase toda implementação:
- Fuso e horário de corte. Uma venda às 23h50 em Brasília já é dia seguinte em UTC. Além disso, alguns adquirentes consideram vendas após um horário de corte como do dia seguinte. A data base precisa ser a data da captura em
America/Sao_Paulo, já aplicado o corte. - Dias corridos ou úteis. O mais comum é somar dias corridos e, se o resultado cair em fim de semana ou feriado, empurrar para o próximo dia útil. Mas confira, porque há contratos que contam dias úteis desde o início.
- Feriados. O que vale é o calendário bancário nacional. Feriado municipal da cidade do lojista não entra. Mantenha os feriados numa tabela, carregados com antecedência, e não numa lib que calcula Páscoa e torce pelo resto.
use Carbon\CarbonImmutable; final class SettlementCalendar { /** @param array<string, true> $holidays chaves no formato Y-m-d */ public function __construct(private array $holidays) {} public function nextBusinessDay(CarbonImmutable $date): CarbonImmutable { while ($date->isWeekend() || isset($this->holidays[$date->toDateString()])) { $date = $date->addDay(); } return $date; } public function expectedDate(CarbonImmutable $saleDate, int $installment, int $daysPerInstallment = 30): CarbonImmutable { return $this->nextBusinessDay($saleDate->addDays($daysPerInstallment * $installment)); } }
Registro o calendário no container como singleton, carregando os feriados dos próximos dois anos. Assim a geração de parcelas não consulta o banco a cada data. Se o ano seguinte ainda não tiver feriados cadastrados, o job de geração falha alto em vez de gerar datas erradas sem avisar ninguém.
Quais status um recebível pode ter e quem pode mudá-los?
O ciclo de vida que uso é este:
scheduled: gerado, aguardando a data prevista.settled: liquidado, comsettled_datepreenchida.anticipated: vendido numa antecipação, comanticipation_id.held: retido, normalmente por disputa.cancelled: cancelado por estorno total antes da liquidação.
As transições válidas são poucas. scheduled pode ir para qualquer outro status, held pode voltar para scheduled ou ir para cancelled, e settled e anticipated são finais para o recebível. Uso o mesmo padrão descrito em máquina de estados para status de pagamento no Laravel: a transição é um método no model, ele valida a origem e faz o UPDATE ... WHERE status = :origem, para que duas transições concorrentes não passem. A retenção por disputa segue o fluxo do post sobre como modelar chargeback e reter saldo no Laravel. Lá a disputa aponta para os recebíveis que ela segura.
Como calcular a antecipação de recebíveis sem bagunçar a agenda?
Antecipação é o lojista receber hoje um recebível que venceria no futuro, pagando uma taxa proporcional ao prazo antecipado. O erro clássico é editar o net_cents da parcela para descontar a taxa de antecipação. Com isso você perde a informação de quanto o lojista receberia, e a conciliação com o adquirente, que continua liquidando o valor original na data original para quem comprou o recebível, deixa de bater.
O modelo que funciona tem uma entidade anticipations com requested_at, a taxa aplicada, o total bruto e o total líquido. Os recebíveis antecipados apontam para ela via anticipation_id e mudam de status. Os valores originais ficam intactos.
Para a taxa, o mais comum é juros simples pro rata dia sobre o líquido de cada parcela. Há operações com juros compostos, então confirme a fórmula do contrato.
final class AnticipationCalculator { public function feeFor(Receivable $r, CarbonImmutable $today, int $monthlyRateBp): int { $days = (int) $today->startOfDay()->diffInDays($r->expected_date->startOfDay()); // net * (bp / 10000) * (days / 30), arredondando meio centavo para cima return intdiv($r->net_cents * $monthlyRateBp * $days + 150_000, 300_000); } }
Calcule a taxa por parcela e some depois. Assim cada recebível guarda a própria taxa de antecipação, e o extrato explica de onde veio cada centavo. Toda a operação roda numa transação com lockForUpdate() nos recebíveis selecionados e filtro status = 'scheduled'. Isso impede que uma retenção por chargeback chegue no meio e o mesmo recebível seja antecipado e retido ao mesmo tempo.
No lado contábil, a antecipação gera lançamentos: crédito no saldo disponível do lojista pelo líquido, débito na conta de recebíveis futuros e a taxa como receita. Se você já tem um ledger de partidas dobradas para o saldo da conta digital, a agenda vira o detalhamento da conta de recebíveis futuros. O saldo dessa conta precisa ser igual à soma dos recebíveis scheduled e held, e essa checagem vira um job diário.
O que fazer com estorno parcial de uma venda parcelada?
Estorno parcial é onde os modelos ingênuos quebram. O lojista vendeu R$ 1.000 em 10x, já recebeu duas parcelas e estornou R$ 300. O adquirente não devolve nada: ele desconta das próximas liquidações, e às vezes de parcelas de outras vendas se as dessa não cobrirem o valor.
Por isso não reescrevo as parcelas. Crio um ajuste, que é um recebível com net_cents negativo, installment_number nulo ou de uma série própria, expected_date na próxima liquidação prevista e referência ao estorno. O extrato fica assim: "parcela 3: +R$ 96,50; ajuste de estorno: −R$ 300,00". É exatamente o que o arquivo do adquirente vai mostrar, e o lojista entende o que aconteceu.
O trade-off é que a consulta "quanto ainda vou receber desta venda" fica mais complexa, porque precisa somar parcelas e ajustes. Prefiro isso a ter um histórico que não corresponde a nada que o adquirente reconheça.
Como conciliar a agenda local com a agenda do adquirente?
Todo adquirente e subadquirente expõe a agenda de alguma forma: endpoint de recebíveis, arquivo diário de conciliação ou webhook de liquidação. A conciliação roda em dois momentos. O primeiro é logo após a geração, comparando a agenda prevista local com a prevista dele. O segundo é após a liquidação, comparando o que estava previsto com o que caiu.
A chave de casamento precisa ser estável. Em cartão, costuma ser o NSU ou o identificador da transação no adquirente mais o número da parcela. Nunca use valor e data como chave, porque é justamente isso que você quer comparar.
$local = Receivable::query() ->where('merchant_id', $merchantId) ->whereBetween('expected_date', [$from, $to]) ->get() ->keyBy('acquirer_reference'); $divergences = []; foreach ($acquirerSchedule as $item) { $r = $local->pull($item['reference']); if ($r === null) { $divergences[] = ['type' => 'missing_local', 'ref' => $item['reference']]; continue; } if ($r->net_cents !== $item['net_cents']) { $divergences[] = ['type' => 'amount', 'ref' => $item['reference'], 'diff' => $item['net_cents'] - $r->net_cents]; } if (! $r->expected_date->isSameDay($item['expected_date'])) { $divergences[] = ['type' => 'date', 'ref' => $item['reference']]; } } foreach ($local as $ref => $r) { $divergences[] = ['type' => 'missing_acquirer', 'ref' => $ref]; }
Cada tipo de divergência costuma apontar para uma causa diferente:
- Diferença de 1 centavo em várias parcelas: regra de resto ou de arredondamento de taxa diferente da do adquirente.
- Diferença grande numa parcela: tarifa errada aplicada na venda ou ajuste de estorno que você não registrou.
- Data deslocada em um dia: feriado faltando no calendário ou fuso e horário de corte errados na data base.
- Recebível só do lado do adquirente: venda que não chegou por webhook. Vale reprocessar a partir da agenda.
- Recebível só do lado local: venda cancelada no adquirente sem você saber, ou antecipação registrada por ele e não por você.
Eu gravo as divergências numa tabela com status próprio (open, explained, fixed) e nunca corrijo o recebível em silêncio. Quando a causa é sistemática, como no item 1, a correção é no gerador. Depois disso, um job recalcula as vendas afetadas e registra que o fez.
Gerar a agenda localmente ou só espelhar a do adquirente?
Há dois caminhos, e eu já vi os dois em produção:
| Critério | Gerar localmente | Espelhar a do adquirente |
|---|---|---|
| Extrato disponível na hora da venda | Sim | Só quando o adquirente publicar |
| Esforço de implementação | Alto: tarifas, calendário, arredondamento | Baixo |
| Detecta erro do adquirente | Sim | Não |
| Funciona com vários adquirentes | Uma regra por adquirente | Um parser por adquirente |
| Risco principal | Divergência de regra | Depender do atraso e da disponibilidade do adquirente |
Para uma fintech que vende maquininha e mostra saldo futuro ao lojista, gerar localmente é praticamente obrigatório. O lojista quer ver a agenda na hora em que passa o cartão, e você quer saber quando o adquirente cobrou uma taxa diferente da contratada. Espelhar faz sentido num produto em que a agenda é informativa e o dinheiro não passa por você.
Conclusão prática
Se você vai implementar agenda de recebíveis amanhã, a ordem que eu seguiria é esta:
- Pegue o contrato e o arquivo de agenda do adquirente antes de escrever código. Anote a regra do resto, a regra de taxa em parcelado, o prazo por produto, o horário de corte e a fórmula de antecipação.
- Crie a tabela de recebíveis com
gross,feeenetgravados e unique por venda e parcela. - Escreva o gerador de parcelas com teste de propriedade garantindo que as somas fecham.
- Implemente o calendário com feriados em tabela, e faça o job falhar se faltar o ano seguinte.
- Trate estorno como ajuste negativo e antecipação como entidade própria. Nunca edite o valor de uma parcela existente.
- Coloque a conciliação para rodar desde o primeiro dia, mesmo que só gere relatório. As primeiras divergências vão mostrar exatamente qual regra do contrato você entendeu errado.
O trabalho difícil aqui não é o código, que é curto. É fazer o seu sistema concordar, centavo por centavo e dia por dia, com um sistema que você não controla. A conciliação diária é o que mostra se essa concordância continua de pé.