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:

  1. A parcela 3 pode ser antecipada e as outras não.
  2. Um chargeback pode reter a parcela 7 em diante.
  3. Um estorno parcial pode ser descontado das próximas parcelas, ou até de outra venda.
  4. 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, fee e net gravados, 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_date separado de settled_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:

  1. 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.
  2. 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.
  3. 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, com settled_date preenchida.
  • anticipated: vendido numa antecipação, com anticipation_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:

  1. Diferença de 1 centavo em várias parcelas: regra de resto ou de arredondamento de taxa diferente da do adquirente.
  2. Diferença grande numa parcela: tarifa errada aplicada na venda ou ajuste de estorno que você não registrou.
  3. Data deslocada em um dia: feriado faltando no calendário ou fuso e horário de corte errados na data base.
  4. Recebível só do lado do adquirente: venda que não chegou por webhook. Vale reprocessar a partir da agenda.
  5. 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:

  1. 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.
  2. Crie a tabela de recebíveis com gross, fee e net gravados e unique por venda e parcela.
  3. Escreva o gerador de parcelas com teste de propriedade garantindo que as somas fecham.
  4. Implemente o calendário com feriados em tabela, e faça o job falhar se faltar o ano seguinte.
  5. Trate estorno como ajuste negativo e antecipação como entidade própria. Nunca edite o valor de uma parcela existente.
  6. 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é.