# Agenda de recebíveis no Laravel: como modelar parcelas do cartão, D+30 e antecipação

> Uma venda parcelada no cartão são N recebíveis com data, taxa e status próprios. Mostro como modelar a agenda no Laravel, calcular D+30 e antecipar.

Por Lucas Roberto · Pagamentos · publicado em 2026-10-30 · 12 min de leitura
Versão web: https://lucasroberto.com/blog/agenda-de-recebiveis-no-laravel-como-modelar-parcelas-do-cartao-d30-e-antecipacao

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.

```php
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](/blog/dinheiro-no-laravel-centavos-em-inteiro-cast-de-money-e-split-sem-perder-centavo). 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.

```php
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.

```php
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](/blog/maquina-de-estados-para-status-de-pagamento-no-laravel-como-impedir-transicoes-invalidas): 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](/blog/chargeback-no-laravel-como-modelar-disputas-reter-saldo-e-nao-perder-o-prazo-de-defesa). 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.

```php
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](/blog/ledger-de-partidas-dobradas-no-laravel-e-postgresql-como-modelar-o-saldo-de-uma-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.

```php
$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é.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
