Pular para conteúdo

0048 — Geração automática de cobranças e tela de gestão

Status CONCLUÍDA
Branch spec/0048-gestao-de-cobrancas
Criada em 2026-08-30
Apps afetados api · web · e2e
Revisão do problema Aprovada (rodada 3)
Revisão da experiência Aprovada (rodada 6)
Revisão do detalhamento Aprovada (rodada 3)
Revisão da implementação —

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.

  1. A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
  4. A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.

Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.


Parte I — O problema

Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.

1. Dor

Hoje, cada cobrança mensal (Billing) de uma garantia ACTIVE nasce de uma ação manual: alguém abre a página de detalhe daquela garantia específica, clica em "Nova cobrança", digita a data de vencimento e o valor à mão, e salva. Não existe sugestão de data (o campo nasce vazio), não existe cálculo de valor (o operador digita), e não existe lembrete: nada no sistema avisa que "a cobrança de setembro da garantia X ainda não foi criada". Como cada garantia só mostra as próprias cobranças, dentro da própria página, não há hoje nenhum lugar em que alguém veja, de uma vez, o que está pendente ou vencido em todas as garantias — a única forma de auditar isso é abrir garantia por garantia.

O custo de não fazer isso automaticamente é dobrado: (a) quem é responsável por criar a cobrança do mês seguinte pode simplesmente esquecer — e o esquecimento não aparece em lugar nenhum até alguém perguntar "cadê a cobrança deste mês?" —, e (b) mesmo quem lembra de tudo não tem como responder "o que está pendente e o que venceu, em todas as garantias?" sem abrir uma por uma. Confirmado pelo usuário: isso já aconteceu — pessoas responsáveis por operar o sistema esquecem de gerar a cobrança, o inquilino não é cobrado no mês, e ninguém percebe que algo ficou pendente até o problema aparecer de outro jeito.

Multa por atraso (TenantSettings.late_fee_percent) já existe como configuração, mas nenhum código a aplica hoje — quem dá baixa numa cobrança vencida precisa calcular a multa por fora e digitar o valor já somado, ou simplesmente não aplicar multa nenhuma.

2. Quem sente

Quem sente primeiro e com mais frequência é o operador financeiro do lado da garantidora — hoje, no modelo de papéis do código, na prática quem opera isso é o ADMIN_TENANT, com billings:update (que cobre criar, editar, dar baixa e marcar atraso). ADMIN_GLOBAL também detém essa permissão pelo desenho geral de RBAC (rbac_seed.py:156-157, que concede a ele quase todas as permissões declaradas — as duas exceções são do dossiê de garantia, spec 0040, e não afetam cobrança), mas não é quem sente esta dor — o CLAUDE.md já estabelece que o SaaS admin administra a plataforma, não opera dado de negócio do cliente. É caminho obrigatório, não exceção: toda garantia que chega a ACTIVE precisa de uma cobrança por mês até o fim do prazo, então o processo se repete uma vez por garantia por mês, para sempre, enquanto durar a carteira ativa.

ANALYST, OPERATOR e VIEWER sentem uma dor derivada, mais leve: têm permissão só de leitura (billings:list/billings:read) e hoje também precisam abrir garantia por garantia para ver o que está pendente, porque não existe tela cross-garantia. AGENCY_USER (a imobiliária) também só lê, e só as próprias garantias.

Não há volume de operação para medir — a produção está no ar mas ainda sem cliente (ver CLAUDE.md, "Estágio do produto"), então "quantas cobranças por mês" não tem uma resposta real hoje. O alcance vem do desenho, não da contagem: é trabalho que se repete para toda garantia ativa, todo mês, sem exceção — não um caso raro.

3. A jornada de hoje

Passo a passo, hoje, sem a feature:

  1. Uma garantia é emitida e depois assinada — vira ACTIVE (o único ato que libera cobrança, por causa da RN12 da spec 0038: BillingService.create recusa criar Billing em garantia que não está ACTIVE).
  2. Para cobrar o primeiro mês (setup + 1ª parcela, já calculados em Guarantee.total_upfront) e cada mês seguinte, alguém entra na página daquela garantia, na aba de cobranças (componente BillingList), e clica em "Nova cobrança" (BillingCreateDialog).
  3. O diálogo pede data de vencimento (campo de data vazio, sem sugestão de dia) e valor (campo livre, sem preencher com monthly_value). O operador calcula e digita os dois à mão, garantia por garantia, mês por mês.
  4. Quando o inquilino paga, o mesmo operador volta a essa mesma página, abre "Marcar como pago" (BillingPayDialog) e digita o valor pago. Se o pagamento veio com atraso, não há cálculo de multa: o operador soma por fora (calculadora, planilha) e digita o valor já com o acréscimo, ou simplesmente não aplica a multa.
  5. Se o pagamento atrasa e ainda não foi pago, uma ação separada e também manual (mark-overdue) muda o status para OVERDUE — não há nenhuma rotina no sistema que faça essa transição sozinha; hoje ela só acontece se alguém lembrar de clicar.
  6. Se a garantia é encerrada antes do fim do prazo (TERMINATED), nada cancela as cobranças futuras que porventura já existissem como PENDING — elas continuam lá, sem dono, a não ser que alguém lembre de tratá-las à mão.
  7. Não existe, em nenhum ponto deste caminho, uma tela que junte as cobranças de mais de uma garantia. O que existe é um mecanismo parcial: a Central de Operações (operations_center_service.py:146-155,249-260) já lista, entre todas as garantias, as cobranças OVERDUE há mais de 7 dias, num alerta e numa fila de prioridade — mas só OVERDUE, não PENDING (não ajuda a ver o que ainda vai vencer, só o que já venceu), e o link de cada item aponta para /billings?status=OVERDUE e /billings/{id}, rotas que hoje não levam a lugar nenhum porque a tela /billings está marcada comingSoon (nav-groups.tsx:171,179) — não foi construída ainda.

Onde quebra: os passos 2–3 dependem inteiramente da memória de quem opera — não há lembrete, sugestão de valor/data, nem lista consolidada que cubra o que ainda está por vencer. É nesse ponto que o esquecimento relatado pelo usuário acontece: ninguém vê que uma cobrança está faltando até o problema já ter acontecido fora do sistema.

O que já se tentou: dois mecanismos, os dois insuficientes. O principal é o fluxo manual (BillingCreateDialog/BillingPayDialog), construído junto com a emissão da garantia na spec 0038 — é o processo oficial de hoje, e falha por depender de lembrança individual, garantia por garantia. O outro é o alerta de OVERDUE da Central de Operações, descrito no passo 7 — cobre só o que já venceu (não PENDING), não sugere data nem valor, não calcula multa, e a rota para a qual aponta ainda não existe como tela.

4. Resultado esperado

Quando uma garantia é assinada e vira ACTIVE, todas as cobranças do prazo inteiro (a parcela de setup + 1º mês e uma por mês até o fim da vigência) já existem, com data e valor corretos, sem nenhuma digitação manual. A partir daí, o trabalho humano que resta é dar baixa (marcar como pago) e, quando necessário, ajustar um caso pontual — não mais criar a cobrança recorrente do zero todo mês.

Existe uma tela dedicada (fora do detalhe de uma garantia específica) que mostra, entre todas as garantias, o que está pendente, o que venceu e o que já foi pago — sem precisar abrir garantia por garantia para descobrir isso.

Como se sabe que resolveu: (a) nenhuma cobrança recorrente é criada manualmente numa garantia emitida depois desta spec — o único registro manual que resta é o pontual, fora do calendário padrão; (b) a pergunta "o que venceu esta semana, em todas as garantias?" tem resposta em uma tela, sem abrir nada individualmente; (c) uma garantia encerrada antes do prazo não deixa cobrança futura órfã pendurada; (d) uma cobrança paga com atraso reflete a multa de TenantSettings.late_fee_percent sem cálculo manual por fora.

5. Fora de escopo

  • Fechamento e repasse agregado por imobiliária, e emissão de nota fiscal — ambos descritos no ROADMAP.md (plano de entregas, item futuro "E2 · Cobrança recorrente & Fechamento": "Fatura de repasse agregada por imobiliária" e "emissão de nota fiscal") como a etapa seguinte, de consolidação. Isto aqui cobre só a cobrança individual inquilino↔garantidora; a consolidação que a garantidora cobra da imobiliária, e a nota fiscal correspondente, ficam para depois.
  • Emissão de boleto real (PDF, link de pagamento, integração bancária) — Billing continua sendo registro contábil (vencimento, valor, status), não um boleto emitido. Já era assim antes desta spec (ROADMAP.md, "Ausente #4" — seção "4. Emissão de boleto").
  • Rotina automática (job/cron) que marque OVERDUE sozinha quando a data vence — não existe hoje nenhum agendador no sistema, e esta spec não introduz um. A transição para OVERDUE continua sendo uma ação manual do operador; o que muda é só a geração das cobranças no ato da assinatura, não o acompanhamento contínuo do vencimento.
  • Backfill de garantias já ACTIVE antes desta spec, sem cobranças geradas automaticamente — fica fora porque não há cliente real em produção hoje (CLAUDE.md, "Estágio do produto": produção existe, cliente não), então não há carteira ativa para migrar. Quem precisar, no ambiente de desenvolvimento, continua podendo criar cobranças à mão pelo fluxo que já existe.
  • Parcelamento especial ou renegociação de cobrança fora do calendário mensal padrão — fica para quando houver um caso concreto que peça isso; esta spec cobre o calendário regular (setup + mensal) descrito no CLAUDE.md ("Fluxo do dinheiro").

6. Revisão do problema

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 1 2 Reprovado
2 0 1 0 Reprovado
3 0 0 0 Aprovado

Resultado: aprovada na rodada 3.

Pendências aceitas: nenhuma. A recomendação cosmética da rodada 3 (apertar o range de linhas citado do operations_center_service.py de 146-160 para 146-155) foi aplicada antes de fechar a rodada.


Parte II — A experiência

Não preencher antes da revisão da seção 6 estar aprovada.

7. Jornada pretendida

A feature tem duas metades: uma que acontece sem tela, dentro de um fluxo que já existe (a emissão da garantia passa a gerar o calendário inteiro de cobrança), e uma tela nova, para acompanhar o que já foi gerado. As duas são descritas separadamente.

7.A Geração automática — sem tela nova, dentro do fluxo de emissão

  • Ponto de entrada: o mesmo de hoje — o diálogo "Emitir garantia" (GuaranteeCreateDialog.tsx, spec 0039), aberto a partir do detalhe de uma Análise de Crédito aprovada.
  • Jornada, caso feliz: o operador preenche o que já preenche hoje (início e fim de vigência) e, além disso, escolhe o dia de vencimento mensal, num campo novo de seleção cujas opções vêm de TenantSettings.allowed_due_days do tenant — não um campo de texto livre, para não nascer uma cobrança num dia que o tenant não usa. Confirma a emissão como hoje. A garantia nasce PENDING_SIGNATURE; nada de cobrança existe ainda (RN12 continua valendo). Quando alguém registra a assinatura (register_signature, o mesmo botão que já existe no detalhe da garantia, e o único ato que leva a ACTIVE), o calendário inteiro de cobranças passa a existir — a parcela de setup + 1º mês, com vencimento no dia escolhido, e uma cobrança mensal seguinte por mês até o fim da vigência. Ninguém abre um formulário de cobrança para isso acontecer.

O que continua manual, e por quê. O botão "Nova cobrança" (BillingList.tsx:87-97/BillingCreateDialog) continua existindo na garantia — não para o calendário regular, que passa a nascer sozinho, mas para a mesma flexibilidade que a docstring de apps/api/app/models/billing.py:9 já registra (ajuste, desconto, pagamento parcial) mais o caso que passa a ser o motivo principal dela existir: uma cobrança extra, fora do calendário (uma taxa pontual, por exemplo). Essa cobrança extra pode nascer sem competência — reference_month já é opcional hoje (apps/api/app/schemas/billing.py:33, BillingCreateDialog.tsx:35) — e a tela nova trata isso como a lista embutida já trata: mostra "—" na coluna Competência (BillingList.tsx:132) e o filtro de Competência ganha uma opção "Sem competência" para alcançá-la, além dos meses distintos já existentes. O operador continua podendo criar uma cobrança extra a qualquer momento numa garantia ACTIVE; o que muda é que a cobrança regular de cada mês deixa de depender de alguém lembrar de criá-la. - Custo de uso: um campo novo, uma escolha entre poucas opções (as de allowed_due_days, tipicamente uma ou duas). O sistema já sabe o valor de cada parcela (Guarantee.monthly_value/total_upfront, já calculados na emissão) e a duração (start_date/end_date) — nada disso é perguntado de novo.

7.B Tela de Cobranças — acompanhar o que já existe

  • Ponto de entrada: dois. (a) Menu OPERAÇÃO → Cobranças (nav-groups.tsx:166-172, hoje comingSoon) — a rota /billings passa a abrir a tela. (b) A Central de Operações já linka para lá, de duas formas distintas, e a tela nova responde às duas: os alertas "Cobranças vencidas críticas"/"recém-vencidas" navegam para /billings?status=OVERDUE (operations_center_service.py:154,170,382) — a tela lê o parâmetro status da URL e abre já filtrada por ele; os itens da fila de trabalho navegam para /billings/{id} (:259, o id da cobrança, não da garantia) — como não existe tela de detalhe por cobrança isolada (a única existe dentro do detalhe da garantia), essa rota carrega a cobrança, descobre a garantia dela e redireciona para /guarantees/{guarantee_id}, o mesmo destino que clicar numa linha da tela nova já leva. Hoje nenhuma das duas rotas existe (<Route path="/billings"> ausente em App.tsx), então os dois links caem no catch-all e mostram a página não encontrada.
  • Jornada, caso feliz: o operador financeiro (ADMIN_TENANT) abre Cobranças e vê, numa lista só, as cobranças de todas as garantias — não precisa mais abrir garantia por garantia. Filtra por status (Pendente/Pago/Em atraso/Cancelado) ou por competência, encontra o que precisa (por nome do inquilino ou número da garantia), e registra o pagamento ou marca o atraso sem sair da tela — a ação é por linha, igual ao que já existe hoje dentro do detalhe de uma garantia (BillingList/BillingPayDialog), só que agora agregada. Quem só lê (ANALYST, OPERATOR, VIEWER) vê a mesma lista sem as ações de escrita. A imobiliária (AGENCY_USER) não usa esta tela — continua vendo cobrança só dentro do detalhe da própria garantia, como já é hoje (decisão preexistente da spec 0038; detalhado logo abaixo, em 7.1).
  • Custo de uso: o sistema já sabe inquilino, garantia, imobiliária, valor previsto e vencimento — nada disso é digitado. O único dado que continua sendo digitado é o valor efetivamente pago, no mesmo diálogo de hoje (BillingPayDialog), que passa a vir pré-preenchido com o valor já considerando a multa quando a cobrança está em atraso (TenantSettings.late_fee_percent), em vez de o operador calcular por fora.

7.1 Desktop

7.A (diálogo de emissão): o diálogo hoje é uma pilha vertical de quatro blocos, nesta ordem no desktop — identificação, valores (conferência de LMI/custos de saída/preço), formulário (GuaranteeCreateDialog.tsx:410-467) e notas —, e só identificação e valores usam grade interna; o bloco de formulário é ele mesmo uma pilha vertical (display: flex, flexDirection: column, :415), com início e fim de vigência um abaixo do outro, não lado a lado. O campo novo (dia de vencimento) entra nessa mesma pilha, logo abaixo do fim de vigência — mais um campo empilhado, sem grade nova, sem mudar a ordem dos quatro blocos nem tirar notas de onde está (por último, nos dois viewports).

7.B (tela de Cobranças): segue o padrão de lista do app (docs/ui-patterns.md, "Filtros das listas" e "Tabelas — Padrão", o mesmo de Garantias/Imobiliárias). PageHeader com título "Cobranças" e ícone de recibo (o mesmo do item do menu). Abaixo, FilterBar com busca (inquilino ou número da garantia), FilterSelect de Status e FilterSelect de Competência — filtros no servidor, não em memória (mesma regra das demais listas). É o primeiro FilterSelect do app sobre um campo de mês, não um enum fixo: as opções são os valores distintos de reference_month existentes no tenant, mais recente primeiro, com rótulo por extenso ("Agosto de 2026") sobre o valor YYYY-MM que a API já usa, mais a opção "Sem competência" para alcançar a cobrança extra sem mês definido (7.A). Contagem abaixo da barra ("X cobranças", no singular quando for 1 — mesma regra de docs/ui-patterns.md:371). A tabela, visível por inteiro sem rolar horizontalmente numa tela de desktop comum, tem as colunas: Garantia (nº, clicável), Inquilino, Imobiliária, Competência, Vencimento, Valor (com indicação "+ multa" quando em atraso), Valor pago, Status (StatusBadge, reaproveitando o mapa BILLING_STATUS que já existe), Ações. Clicar na linha (fora da célula de ações) navega para o detalhe da garantia correspondente (/guarantees/{id}), onde está o dossiê completo; a coluna Ações tem os dois botões que já existem hoje em BillingList — "Registrar pagamento" (se PENDING/OVERDUE) e "Marcar inadimplente" (se PENDING) —, condicionados a billings:update, sem precisar sair da tela nova para agir. Paginação fixa no rodapé (ListPagination), como as demais listas.

A imobiliária (AGENCY_USER) não acessa esta tela — nem pelo menu (que já a esconde para esse papel, nav-groups.tsx:170, decisão da spec 0038/RN19a: dar acesso agregado revelaria a estrutura interna de quem a contrata) nem digitando /billings direto na URL, ainda que ela detenha billings:list/billings:read para o uso de hoje (ler cobrança dentro do detalhe da própria garantia). A imobiliária continua vendo cobrança só ali, como já é o caso — o bloqueio da tela nova precisa checar o papel, não só a permissão (seção 10 detalha como).

Não há ação em lote (marcar várias cobranças como pagas de uma vez): o app não tem hoje nenhum padrão de seleção múltipla + ação em lote em nenhuma tela (nenhum uso de checkboxSelection/seleção de linha em todo apps/web/src), e cada baixa pode diferir da outra pela multa aplicada — introduzir esse padrão do zero fica fora desta spec.

7.2 Mobile (375px)

7.A (diálogo de emissão): os dois blocos do meio trocam de posição em 375px (spec 0039: identificação → formulário → valores → notas, em vez de identificação → valores → formulário → notas no desktop — os números nascem abaixo da data que os produz, não acima e fora de vista). O campo novo de dia de vencimento entra na mesma pilha vertical do bloco de formulário nos dois casos, logo abaixo do fim de vigência — mais um campo empilhado, sem mudar a ordem dos blocos.

7.B (tela de Cobranças): a tabela vira RecordCard (components/shared/RecordCard.tsx, o mesmo componente que a lista de Garantias já usa) — um card por cobrança, com code = nº da garantia, status = StatusBadge, title = nome do inquilino, meta (até 3 linhas) = Imobiliária, Competência e Vencimento, amount = valor (com "+ multa" quando em atraso) ou valor pago quando já PAID, e actions = os botões de ação disponíveis como IconButton de 44px, lado a lado — o mesmo padrão que a lista embutida de cobranças já usa hoje no actions do RecordCard (BillingList.tsx:144-167, dois IconButton diretos dentro de um Box com gap, sem menu intermediário). As duas ações aparecem para toda cobrança PENDING (vencida ou não, o mesmo critério de hoje), e só "Registrar pagamento" para OVERDUE; quando nenhuma se aplica (PAID/CANCELLED), o card não tem actions. Tocar no corpo do card (fora dos botões de ação) navega para /guarantees/{id}, o mesmo destino do clique na linha em 7.1 — o mesmo padrão que a lista de Garantias já usa (guarantees/index.tsx:301,311: onClick no RecordCard, e.stopPropagation() nos IconButton de ação, para o toque no botão não também navegar). FilterBar colapsa em MobileFilterBar: a busca continua, o FilterSelect de Status vira FilterPill; o filtro de Competência sai do topo — some por espaço, e não tem substituto equivalente no mobile: quem precisa achar algo de um mês específico usa a busca (por garantia/inquilino, não por mês) ou o filtro de Status vindo da Central de Operações — nenhum dos dois repõe o filtro por competência que sumiu. Paginação vira a barra compacta que ListPagination já usa no mobile nas outras listas — mesmo componente, sem "Carregar mais" (o app não usa mais esse padrão).

Sem interface nenhuma (spec de infraestrutura ou ferramental)? Escreva "sem interface de usuário" aqui e diga por quê. Só vale se for verdade: se alguém vê ou opera algo, tem interface.

7.3 Estados

Vazio. Dois casos, com texto diferente: (a) sem filtro nenhum aplicado e zero cobranças no tenant — "Nenhuma cobrança ainda. Cobranças aparecem aqui automaticamente quando uma garantia é assinada", sem link de ação (não existe "criar cobrança" nesta tela — a criação é automática); (b) com filtro aplicado e zero resultado — "Nenhuma cobrança encontrada com estes filtros" mais um link "Limpar filtros" (mesmo padrão de guarantees-empty-clear). Igual nos dois viewports — é texto e um link, não depende de largura.

Carregando. Linhas/RecordCards em skeleton no lugar dos dados, mantendo a mesma altura da lista carregada — evita o salto de layout quando os dados chegam. Igual nos dois viewports.

Erro (falha ao carregar a lista). Mensagem "Não foi possível carregar as cobranças" mais um botão "Tentar de novo" que refaz a mesma chamada com os mesmos filtros (mesmo padrão e mesmo texto de guarantees-error/guarantees-retry) — a saída é o retry, não um passo a mais. Se a falha for ao registrar um pagamento, ela aparece no próprio BillingPayDialog, como hoje. Se a falha for ao marcar atraso, ela aparece dentro do próprio ConfirmDialog (via prop error) — o padrão que o resto do app já usa para esse componente (apps/web/src/pages/credit-analyses/index.tsx:607, apps/web/src/pages/guarantees/index.tsx:487, apps/web/src/pages/roles/index.tsx:376, users/index.tsx:665,689), não o que BillingList.tsx faz hoje: ali o erro vai para um Alert da página, atrás do ConfirmDialog aberto, e fica invisível até a pessoa fechar o diálogo. A tela nova não herda essa falha; a Parte III decide se BillingList também é corrigida. Nos dois casos, a lista por trás não recarrega sozinha, para não perder o filtro aplicado. Igual nos dois viewports.

Sem permissão. Quem não tem billings:list e digita /billings direto na URL cai em UnauthorizedPage, pelo mesmo guard de rota (ExigeAcesso) que protege as demais listas — o menu já esconde o item para quem não tem a permissão, mas esconder o menu não é o que protege a rota. Dentro da tela, para quem tem billings:list mas não billings:update (ANALYST, OPERATOR, VIEWER), a lista aparece igual e as duas ações de linha ("Registrar pagamento", "Marcar inadimplente") simplesmente não aparecem — não ficam desabilitadas com tooltip, o mesmo comportamento que BillingList já tem hoje. AGENCY_USER é um caso à parte, e não é só "sem billings:update": ela tem billings:list/billings:read, a mesma permissão que abre a rota para os papéis de leitura — mas não deve abrir esta tela (7.1 explica o porquê). Cair em UnauthorizedPage também para ela, e não em lista vazia, é o que evita o meio-estado apontado na revisão: o guard desta rota específica precisa negar por papel, não só liberar por permissão.

8. Revisão da experiência

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 2 2 Reprovado
2 0 3 0 Reprovado
3 1 0 0 Reprovado (não conta contra o limite — ver nota)
4 0 3 0 Reprovado
5 0 2 2 Reprovado
6 0 0 0 Aprovado

Resultado: aprovada na rodada 6, por decisão do usuário de rodar uma verificação além do limite normal de 3 (ver histórico das rodadas abaixo).

O Critical da rodada 3 cita exatamente a frase que a correção da rodada 2 escreveu (a estrutura do GuaranteeCreateDialog em 7.1/7.2) — sinal do CLAUDE.md para isolar: uma correção, uma verificação, antes de seguir, e essa verificação não conta contra o limite (dívida de verificação da correção anterior, não desatenção nova). Verificação isolada: a estrutura batia; só a citação de linha estava deslocada em 1, corrigida.

Contando 1, 2, 4 e 5 (3 não conta): são 4 rodadas normais, uma a mais que o limite. A rodada 4 achou uma lacuna de produto real (o que sobra da criação manual de cobrança, [PERGUNTAR AO USUÁRIO], já respondida pelo usuário: continua, para cobrança extra fora do calendário) e duas lacunas de UX (população do filtro de Competência; toque no RecordCard mobile). A rodada 5 achou mais duas: o link /billings/{id} da fila de trabalho não tinha destino descrito (não é efeito das correções da rodada 4 — estava sem tratamento desde a rodada 1) e a interação entre a cobrança extra sem competência e o filtro novo (esta sim, criada pelas duas correções da própria rodada 4, uma citando a outra). Todas as cinco correções já entraram no texto. Como a rodada 5 misturou achado novo com achado causado pela rodada anterior, não se qualificou para a exceção de isolamento inteira — o laço parou no limite e levou a decisão ao usuário, que pediu a rodada 6. Ela aprovou sem achado nenhum: as duas correções da rodada 5 conferem linha a linha, e nenhuma citação anterior (algumas tocadas de novo nas edições seguintes) regrediu.

Pendências aceitas: duas observações não-bloqueantes da rodada 6, já corrigidas por serem baratas — citação billing.py:9 qualificada para apps/api/app/models/billing.py:9 (havia quatro arquivos billing.py no repo com esse nome base); e a frase sobre o filtro de Competência no mobile reescrita para deixar explícito que nenhuma alternativa citada repõe a função perdida.


Parte III — Detalhamento

Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.

9. Regras de negócio

  • RN1 — Dia de vencimento escolhido na emissão. Guarantee ganha o campo billing_due_day (inteiro), aceito em POST /guarantees (GuaranteeCreate, hoje sem esse campo — schemas/guarantee.py:18-43). Opcional no schema (int | None, default None) — divergência da redação original desta RN, que pedia obrigatório; registrada e justificada no §18 (Registro) por não valer a pena quebrar o contrato de POST /guarantees para todo chamador existente por um campo que tem default seguro. Omitido, usa o primeiro valor de TenantSettings.allowed_due_days do tenant (tenant_settings.py:43-45, hoje só exposto como leitura via GET /api/v1/settings, apps/api/app/schemas/tenant_settings.py:171) — que sempre tem ao menos um dia (schema da API já exige de 1 a 5). Informado, precisa constar na lista, senão 422 (BILLING_DUE_DAY_NOT_ALLOWED). Nulo no banco (nullable=True) só para o passado: garantias emitidas antes desta spec ficam com o campo vazio e simplesmente nunca passam pela RN2 — não há backfill (Parte I, "Fora de escopo"). Verificado em GuaranteeService.create.
  • RN2 — Geração automática do calendário completo na assinatura. Quando GuaranteeService.register_signature muda o status para ACTIVE (guarantee_service.py:629), na mesma transação (antes do audit.log de :634-641, depois do self.repo.save(guarantee) de :633) o sistema cria todas as cobranças do prazo:
  • Cobrança de setup: amount = guarantee.total_upfront, due_date = a primeira ocorrência de billing_due_day a partir de (e incluindo) start_date, reference_month = o mês dessa data (YYYY-MM), status = PENDING.
  • Cobranças mensais: amount = guarantee.monthly_value cada, cobrindo os meses do prazo que a cobrança de setup não inclui. A contagem de meses vem de meses_de_vigencia(start_date, end_date) (domain/periodo.py:61-62); se charge_first_month_upfront for verdadeiro (pricing.py:76, é o que faz total_upfront já incluir o 1º mês), a primeira mensal é a do 2º mês; senão, a do 1º. As datas de vencimento vêm de somar_meses(due_date_do_setup, k) (domain/periodo.py:29-38, já grampeia no último dia do mês — 31/01 + 1 mês = 28/02), incrementando k a cada mensal.
  • Todas nascem status = PENDING, sem notes.
  • RN3 — RN12 (spec 0038) permanece intacta. BillingService.create continua recusando Billing fora de ACTIVE (billing_service.py:48-49); a geração automática não contorna essa regra — ela roda exatamente no instante em que a transição acontece, nunca antes.
  • RN4 — Cancelamento automático de cobrança futura ao encerrar, só a do calendário. Quando GuaranteeService.terminate muda o status para TERMINATED (guarantee_service.py:576), na mesma transação (junto ao self.repo.save de :580) toda Billing da garantia com origin = CALENDAR, status = PENDING e due_date estritamente posterior à data do encerramento passa a CANCELLED. Restrita a origin = CALENDAR — divergência da redação original, que não distinguia origem e cancelava qualquer PENDING futura; registrada e justificada no §18 (Registro) porque quebrava a RN13 da spec 0038 ("dívida emitida não some porque a garantia acabou"): uma cobrança lançada à mão (RN6) com vencimento futuro sumia junto. Billing ganha o campo origin (CALENDAR | MANUAL, models/billing.py) para sustentar essa distinção — CALENDAR para o que a RN2 gera, MANUAL para o que BillingService.create lança (default do model, e o que toda cobrança anterior a esta spec recebe via server_default). Cobrança já vencida (PENDING com due_date no passado) ou já OVERDUE não é tocada, de nenhuma origem — pode ser dívida em curso de sinistro (CLAUDE.md, fluxo de sinistro), e cancelar apagaria isso sozinho; fica para tratamento manual.
  • RN5 — Multa por atraso, sugerida, não imposta. Ao abrir "Registrar pagamento" para uma cobrança OVERDUE, ou PENDING com due_date no passado, o valor pré-preenchido (editável) passa a ser amount × (1 + late_fee_percent / 100) de TenantSettings (hoje nunca lido em billing_service.py — Parte I, seção 1), arredondado a duas casas. O cálculo é feito no apps/web; o contrato de POST .../billings/{id}/pay não muda — continua aceitando qualquer amount_paid (pagamento parcial, desconto negociado).
  • RN6 — Criação manual permanece, sem mudança. POST /guarantees/{id}/billings (BillingService.create) continua existindo e se comportando como hoje — passa a ser o caminho só para cobrança extra, fora do calendário regular (Parte II, 7.A). reference_month continua opcional aí (apps/api/app/schemas/billing.py:33). Grava origin = MANUAL (RN4) — é o que a mantém fora do cancelamento automático do encerramento.
  • RN7 — Tela de Cobranças é cross-garantia, e por isso pede endpoint novo. Nenhum endpoint hoje lista Billing fora do escopo de uma garantia — GET /guarantees/{id}/billings é por garantia (billing_repo.py:14-24). A tela nova (Parte II, 7.B) precisa de GET /api/v1/billings (lista, filtros de status, reference_month e busca) e GET /api/v1/billings/{billing_id} (detalhe mínimo, usado só para resolver o redirecionamento do link da Central de Operações — Parte II já registra que não existe tela de detalhe por cobrança isolada).
  • RN8 — Filtro "Sem competência". reference_month já é opcional em Billing (RN6). O filtro de Competência de GET /api/v1/billings aceita o valor sentinela sem-competencia para alcançar as cobranças sem esse campo — sem ele, ficariam invisíveis para qualquer filtro de mês.
  • RN9 — ADMIN_GLOBAL não escreve cobrança, fechando uma inconsistência de permissão que hoje só não é explorável por um detalhe acidental. apps/web/src/pages/guarantees/components/BillingList.tsx:49 já libera "Registrar pagamento"/"Marcar inadimplente" para ADMIN_GLOBAL por um bypass explícito (isAdminGlobal || (user?.permissions ?? []).includes("billings:update")), e o rbac_seed.py:156-157 concede billings:update a esse papel por estar incluída na regra geral ("todas menos as duas do dossiê"). Hoje isso não chega a gravar: BillingService._get_billing (billing_service.py:140-155) resolve a cobrança filtrando por current_user.tenant_id diretamente, sem o padrão "None = sem filtro" que tenant_scope/_get_guarantee usam — ADMIN_GLOBAL tem tenant_id = None, e Billing.tenant_id == None não bate com nenhuma cobrança real, então a chamada devolve 404 mesmo hoje, mesmo com os dois UUIDs certos em mãos. É proteção por acidente, não por desenho: um dia em que _get_billing for alinhado ao padrão de _get_guarantee (o que seria, isoladamente, uma correção razoável), o bypass do front passaria a gravar de verdade — e a RN7 (lista cross-tenant) é exatamente o que entregaria os UUIDs de bandeja nesse dia. Esta spec fecha a inconsistência agora, na camada que deveria bloquear por desenho: billings:update entra na subtração de _ROLE_PERMISSIONS["ADMIN_GLOBAL"] em rbac_seed.py:157, ao lado de guarantees:dossier_attach/guarantees:dossier_amend (mesmo padrão da spec 0040). O bypass de BillingList.tsx:49 é removido — a condição passa a ser só (user?.permissions ?? []).includes("billings:update"), igual a qualquer outro papel. A leitura (billings:list_all, RN7) continua concedida a ADMIN_GLOBAL — é só a escrita que fecha (10.5 explica por quê a leitura segue o precedente de guarantees:list, e por quê a escrita não).

Autorização tem seção própria (10) — não dilua as regras de acesso no meio das demais.

10. Autorização e isolamento

10.1 Matriz de papéis

Papel Pode Não pode
ADMIN_GLOBAL Encerrar garantia (já podia — terminate não tem guarda de papel, e esta spec não muda isso); ler GET /billings — de todos os tenants, mesmo padrão de GET /guarantees (guarantees.py:104, tenant_scope devolve None para ele) Emitir e assinar garantia — já recusado hoje, sem relação com esta spec (PLATFORM_ADMIN_CANNOT_EMIT/PLATFORM_ADMIN_CANNOT_AMEND, specs 0039/0040). Pagar ou marcar inadimplente — RN9 fecha uma inconsistência de permissão (billings:update concedido, bypass no front) que hoje só não grava por um detalhe acidental de _get_billing, não por desenho
ADMIN_TENANT Tudo: emitir com billing_due_day, ver a lista nova, pagar, marcar inadimplente, criar cobrança extra —
ANALYST Ver a lista nova (só leitura) Pagar, marcar inadimplente, criar cobrança extra (sem billings:update, como hoje)
OPERATOR Ver a lista nova (só leitura) Idem ANALYST
VIEWER Ver a lista nova (só leitura) Idem ANALYST
AGENCY_USER Ver cobrança dentro do detalhe da própria garantia, como hoje (billings:list/billings:read, escopado por partner_agency_id) Acessar GET /billings e GET /billings/{id} — não recebe billings:list_all (Parte II, 7.B: decisão preexistente da spec 0038/RN19a de não expor o módulo agregado ao canal)

O negativo do AGENCY_USER é o que a Parte II já apontou como o ponto delicado desta spec: ela detém billings:list/billings:read (para o uso de hoje) mas não billings:list_all — é essa ausência, não um if de papel no meio do código, que a impede de alcançar a tela nova mesmo digitando a URL direto.

10.2 Permissões

Endpoint Permissão Nova?
POST /guarantees (com billing_due_day) guarantees:create Não — campo novo no schema existente
POST /guarantees/{id}/signature (dispara RN2) guarantees:sign Não
POST /guarantees/{id}/terminate (dispara RN4) guarantees:update Não
GET /guarantees/{id}/billings billings:list Não
POST/PATCH .../billings, .../pay, .../mark-overdue billings:update (RN9: sem ADMIN_GLOBAL) Não, mas a concessão a ADMIN_GLOBAL muda (RN9)
GET /api/v1/billings billings:list_all Sim
GET /api/v1/billings/{billing_id} billings:list_all Sim

billings:list_all entra em rbac_seed.py para ADMIN_GLOBAL, ADMIN_TENANT, ANALYST, OPERATOR, VIEWER — não para AGENCY_USER (10.1). É permissão de leitura só: as ações de escrita da lista nova (pagar, marcar inadimplente) continuam chamando os endpoints existentes por garantia (.../guarantees/{gid}/billings/{bid}/pay), que já exigem billings:update — a lista nova não precisa de um verbo de escrita próprio.

billings:update sai da subtração implícita de ADMIN_GLOBAL — hoje ele a detém por estar incluída na regra geral de _ROLE_PERMISSIONS["ADMIN_GLOBAL"] (rbac_seed.py:156, "todas menos as duas do dossiê"); RN9 acrescenta billings:update à subtração de rbac_seed.py:157, ao lado das duas já excluídas.

10.3 Isolamento por garantidora (tenant)

  • billing_due_day não é FK — é um inteiro validado contra TenantSettings do próprio tenant do solicitante (RN1), nunca aceito de outro tenant.
  • GET /api/v1/billings e GET /api/v1/billings/{id} filtram por tenant_id no banco, sem exceção — mesmo padrão de GuaranteeService.list (guarantee_service.py:294, tenant_scope(current_user), from app.services.scoping import tenant_scope). Cobrança de outro tenant em GET /billings/{id} → 404.
  • A RN2 e a RN4 não recebem FK nova nenhuma — operam sobre a própria Guarantee já resolvida no escopo por register_signature/terminate, que já filtram por tenant.

10.4 Isolamento por imobiliária

AGENCY_USER não alcança GET /billings/GET /billings/{id} — RN de permissão (10.1/10.2), não de filtro: sem billings:list_all, a rota devolve 403 antes de qualquer consulta ao banco (require_permission), não 404. O que ela já lê hoje (GET /guarantees/{id}/billings, escopado por partner_agency_id) não muda.

A função que resolve esse escopo é agency_scope, definida em apps/api/app/services/scoping.py:50.

10.5 Plataforma × cliente (SaaS admin)

Leitura: ADMIN_GLOBAL vê GET /billings de todos os tenants — não por exceção desta spec, mas porque é o mesmo desenho já em produção para GET /guarantees (guarantees.py:104: "ADMIN_GLOBAL — vê garantias de todos os tenants"). Cobrança é dado de negócio do cliente, então em princípio seria o caso "não aceitável" da regra geral (CLAUDE.md) — mas o precedente já existe no recurso irmão, e divergir aqui criaria uma segunda regra para o mesmo domínio sem motivo. Se este precedente for julgado errado, é o portão do detalhamento que deve dizer isso — não foi decisão tomada por esta spec, foi herdada.

Escrita: aqui a spec não herda — RN9 fecha explicitamente billings:update para ADMIN_GLOBAL. A leitura cross-tenant tem precedente direto (guarantees:list); a escrita não tem — nenhum endpoint de escrita de dado de negócio do cliente concede a ADMIN_GLOBAL hoje, exceto este bypass pontual e não-intencional de billings:update (herdado da regra geral "todas menos o dossiê", nunca decidido caso a caso). A lista cross-tenant desta spec tornaria esse bypass trivialmente explorável (RN9, achado da revisão), então a spec fecha a lacuna em vez de propagá-la.

A tela do SaaS admin (/app/admin/*) não muda — esta feature não expõe cobrança ali, só na tela da garantidora.

11. Impacto por app

apps/api

  • Modelo: Guarantee.billing_due_day: int | None (models/guarantee.py, perto dos demais campos de preço). Migration nova (down_revision = "b1d3f5i7l9m0"): ALTER TABLE guarantees ADD COLUMN billing_due_day INTEGER NULL — sem NOT NULL (RN1), então sem necessidade de valor default para linhas existentes. downgrade: DROP COLUMN.
  • Schemas: GuaranteeCreate (schemas/guarantee.py:18-43) ganha billing_due_day: int = Field(..., description=...). GuaranteeResponse ganha o mesmo campo, para o apps/web exibir/reenviar se precisar. Novos: BillingListItem/BillingListResponse (paginado, mesmo formato de PaginatedResponse que GuaranteeService.list já usa) para GET /api/v1/billings.
  • Endpoints (permissão — seção 10.2):
  • POST /guarantees — sem mudança de rota, GuaranteeCreate com o campo novo.
  • GET /api/v1/billings — novo, billings:list_all. Filtros: status, reference_month (aceita sem-competencia), search (nome do inquilino ou nº da garantia). Paginado.
  • GET /api/v1/billings/{billing_id} — novo, billings:list_all. Resposta inclui guarantee_id, para o apps/web resolver o redirecionamento (RN7).
  • GET /api/v1/billings/reference-months — novo, billings:list_all. Decisão tomada na implementação (§18): a Parte II prometia o FilterSelect de Competência populado pelos meses distintos do tenant sem que o detalhamento original desse uma fonte para essa lista. Registrado antes de /{billing_id} no router, para não ser capturado pela rota de detalhe.
  • Os demais endpoints de Billing (create, update, pay, mark-overdue) não mudam — RN6.
  • Regras no service:
  • GuaranteeService.create — valida billing_due_day contra TenantSettings.allowed_due_days (RN1).
  • GuaranteeService.register_signature — gera o calendário completo de Billing após self.repo.save(guarantee) (RN2), usando BillingRepository para persistir em lote na mesma transação (CommitRoute já garante atomicidade).
  • GuaranteeService.terminate — cancela Billing PENDING futura (RN4).
  • BillingService ganha list_all/get_by_id_cross_guarantee (ou nome equivalente), reaproveitando tenant_scope/agency_scope de services/scoping.py — mesmo padrão de GuaranteeService.list (10.3).
  • BillingRepository ganha os métodos de consulta cross-garantia (filtro por status, reference_month, busca por nome do inquilino via join em Guarantee/tomador — o mesmo join que GuaranteeService.list já faz para a busca por nome do tomador).
  • RBAC: app/db/rbac_seed.py ganha a permissão billings:list_all na lista _PERMISSIONS e nos conjuntos de ADMIN_GLOBAL, ADMIN_TENANT, ANALYST, OPERATOR, VIEWER — não no de AGENCY_USER (10.2). E a subtração de _ROLE_PERMISSIONS["ADMIN_GLOBAL"] (rbac_seed.py:157) ganha billings:update, ao lado de guarantees:dossier_attach/guarantees:dossier_amend (RN9).
  • Sem mudança: app/services/operations_center_service.py — os action_url (/billings?status=OVERDUE, /billings/{id}) que ele já emite passam a apontar para uma tela que existe; nenhuma linha dele muda.
  • Testes: caminho feliz de RN1 (dia aceito), RN1 negativo (dia fora de allowed_due_days → 422), RN2 com charge_first_month_upfront verdadeiro e falso (números de cobrança e datas diferentes), RN3 (assinar sem billing_due_day preenchido — garantia antiga — não gera nada, não quebra), RN4 (cobrança futura cancela, cobrança vencida não), GET /billings cross-tenant (outro tenant não aparece), GET /billings/{id} de outro tenant → 404, AGENCY_USER em GET /billings → 403, e RN9: ADMIN_GLOBAL em .../billings/{id}/pay e .../mark-overdue → 403 pela permissão — o teste que fixa a proteção por desenho, para não depender do detalhe acidental de _get_billing que a blindava até aqui.

apps/web

  • GuaranteeCreateDialog.tsx (credit-analyses/components/) — campo Select novo, dia de vencimento, populado por TenantSettings.allowed_due_days (já disponível via GET /api/v1/settings, sem chamada nova). Entra na pilha do bloco formulario (Parte II, 7.1/7.2), logo abaixo do fim de vigência, nos dois viewports.
  • Módulo novo pages/billings/ — index.tsx (a lista cross-garantia, Parte II 7.B), api.ts (listBillings, getBilling). Reaproveita BillingPayDialog, o ConfirmDialog de "Marcar inadimplente" (já existentes em pages/guarantees/components/, importados, não copiados) e o RecordCard de components/shared/RecordCard.tsx para o layout mobile.
  • Correção incluída: BillingList.tsx (a lista embutida na garantia) passa a passar error ao ConfirmDialog de "Marcar inadimplente" — hoje esse erro vai para o Alert da página, atrás do diálogo aberto (Parte II, 7.3, achado da rodada 2). Pequeno, mas deixaria as duas listas com comportamento diferente para o mesmo componente se ficasse de fora.
  • Correção de segurança incluída (RN9): BillingList.tsx:49 perde o bypass isAdminGlobal || — a condição de podeEscrever passa a ser só (user?.permissions ?? []).includes("billings:update"), igual a qualquer outro papel. A tela nova (pages/billings/) usa a mesma condição desde o início, sem herdar o bypass.
  • nav-groups.tsx:169 — o item "Cobranças" troca requerPermissao: "billings:list" por requerPermissao: "billings:list_all", para o menu refletir a permissão que de fato governa a rota nova (10.1) em vez de depender só do ocultoParaPapel já existente para esconder do AGENCY_USER.
  • data-testid novos: billings-filter-bar, billings-search, billings-status-filter, billings-competencia-filter, billing-row-{id}, billings-pagination, billings-empty-clear, billings-error, billings-retry, guarantee-billing-due-day-select. Reaproveitados sem mudar: billing-pay-{id}, billing-overdue-{id} (já existem em BillingList.tsx). Acrescentados na implementação, não previstos aqui: billing-pay-dialog/billing-pay-amount-input/billing-pay-confirm/ billing-pay-cancel/billing-pay-error no BillingPayDialog — o diálogo é de antes desta spec e nunca tinha data-testid nenhum, porque nenhum e2e o exercitava; o apps/e2e desta spec é o primeiro a clicar em "Registrar pagamento" de ponta a ponta, e a convenção do apps/e2e/CLAUDE.md ("todo elemento por data-testid, nunca CSS/texto") exigia o atributo para existir.
  • Sider: nav-groups.tsx:171 — remove comingSoon: true do item "Cobranças". Sem mudança em custom-sider/index.tsx — o sider já lê os itens de nav-groups.tsx diretamente (confirmado nesta spec: não há uma segunda lista hardcoded a manter).
  • App.tsx — resources[] e <Route path="/billings"> novos, guardados por ExigeAcesso permissao="billings:list_all" — não billings:list: é essa permissão, não o menu, que barra o AGENCY_USER digitando a URL direto (10.4).
  • Responsividade & PWA: layout conforme Parte II 7.1/7.2. Sem impacto de PWA — tela de dado, sem asset novo nem comportamento offline.

apps/e2e

  • Spec novo tests/e2e/billings.spec.ts: emitir garantia com dia de vencimento → assinar → calendário aparece na tela nova; filtrar por status/competência (incluindo "sem competência" numa cobrança extra); pagar e marcar inadimplente pela tela nova; encerrar garantia com cobrança futura pendente → ela desaparece da lista de pendentes (ou aparece cancelada, conforme o filtro); AGENCY_USER não vê "Cobranças" no menu e recebe bloqueio ao digitar /billings.
  • Seed: tests/fixtures/seeds/ — uma garantia ACTIVE com billing_due_day definido e calendário já gerado (para os testes de filtro/paginação não dependerem de rodar o fluxo de emissão inteiro toda vez).

12. Contrato de API

POST /guarantees — campo novo, opcional, no corpo existente (permissão inalterada, guarantees:create; RN1):

{
  "credit_analysis_id": "uuid-da-analise-aprovada",
  "start_date": "2026-09-01",
  "end_date": "2027-08-31",
  "billing_due_day": 10
}

Omitindo billing_due_day, a API usa o primeiro dia de TenantSettings.allowed_due_days — o corpo de hoje, sem o campo, continua funcionando.

Erro novo: 422 BILLING_DUE_DAY_NOT_ALLOWED — billing_due_day fora de TenantSettings.allowed_due_days do tenant.

GET /api/v1/billings — billings:list_all:

GET /api/v1/billings?status=OVERDUE&reference_month=2026-09&search=fulano&skip=0&limit=50
Authorization: Bearer <token>
{
  "items": [
    {
      "id": "uuid-da-cobranca",
      "guarantee_id": "uuid-da-garantia",
      "guarantee_number": "G202609-00042",
      "renter_name": "Fulano de Tal",
      "partner_agency_name": "Imobiliária X",
      "due_date": "2026-09-10",
      "amount": "1250.00",
      "amount_paid": null,
      "reference_month": "2026-09",
      "status": "OVERDUE",
      "origin": "CALENDAR"
    }
  ],
  "total": 1,
  "page": 0,
  "page_size": 50,
  "pages": 1
}

reference_month=sem-competencia alcança as cobranças sem esse campo (RN8). Sem filtro, tenant do solicitante inteiro (10.3); ADMIN_GLOBAL sem filtro de tenant (10.5). AGENCY_USER → 403 (não tem billings:list_all).

GET /api/v1/billings/{billing_id} — billings:list_all. Mesmo formato de item acima, mais o guarantee_id (já incluso), usado só para o apps/web redirecionar a /guarantees/{guarantee_id} (Parte II, 7.B). Cobrança de outro tenant → 404.

GET /api/v1/billings/reference-months — billings:list_all. Decisão tomada na implementação (§18):

GET /api/v1/billings/reference-months
Authorization: Bearer <token>
["2027-08", "2027-07", "2026-10", "2026-09"]

Mais recente primeiro, sem repetição, sem a cobrança sem competência (reference_month = null) — a opção "Sem competência" do filtro é fixa no apps/web.

Migration 1 <hash>_billing_due_day_em_guarantees.py, down_revision = "b1d3f5i7l9m0":

def upgrade() -> None:
    op.add_column("guarantees", sa.Column("billing_due_day", sa.Integer(), nullable=True))

def downgrade() -> None:
    op.drop_column("guarantees", "billing_due_day")

Migration 2 <hash>_billing_origin_em_billings.py — nasceu do estreitamento da RN4 (§18): Billing ganha origin (CALENDAR | MANUAL), server_default = "MANUAL" para toda linha existente:

def upgrade() -> None:
    billing_origin.create(op.get_bind(), checkfirst=True)
    op.add_column("billings", sa.Column("origin", billing_origin, nullable=False, server_default="MANUAL"))

def downgrade() -> None:
    op.drop_column("billings", "origin")
    billing_origin.drop(op.get_bind(), checkfirst=True)

13. Critérios de aceite

  • [x] CA1. Dado um tenant com allowed_due_days = [10, 20], quando emitir uma garantia com billing_due_day = 10, então a emissão é aceita.
  • [x] CA2. Dado o mesmo tenant, quando emitir com billing_due_day = 15, então a API recusa com 422 BILLING_DUE_DAY_NOT_ALLOWED.
  • [x] CA3. Dado charge_first_month_upfront = true, quando assinar a garantia, então nasce 1 cobrança de setup (total_upfront) e meses_de_vigencia − 1 cobranças mensais (monthly_value), a partir do 2º mês.
  • [x] CA4. Dado charge_first_month_upfront = false, quando assinar, então nasce 1 cobrança de setup e meses_de_vigencia cobranças mensais, a partir do 1º mês.
  • [x] CA5. Dado o dia de vencimento escolhido já ter passado no mês de start_date, quando assinar, então a primeira cobrança vence no mesmo dia do mês seguinte (não no passado).
  • [x] CA6. Dado uma garantia com calendário gerado, quando encerrá-la, então toda cobrança PENDING com vencimento futuro vira CANCELLED, e nenhuma OVERDUE ou PENDING já vencida muda de status.
  • [x] CA7. Dado uma cobrança OVERDUE, quando abrir "Registrar pagamento" na tela nova, então o valor vem pré-preenchido com a multa de late_fee_percent já somada, e o campo continua editável.
  • [x] CA8. Dado o botão "Nova cobrança" na garantia, quando usá-lo, então uma cobrança extra nasce sem afetar o calendário automático — comportamento idêntico ao de hoje.
  • [x] CA9. Dado ADMIN_TENANT, quando abrir "Cobranças" no menu, então vê a lista de todas as cobranças do tenant, com filtro por status e por competência (incluindo "Sem competência").
  • [x] CA10 (negativo — autorização). Dado AGENCY_USER, então o item "Cobranças" não aparece no menu e GET /api/v1/billings devolve 403 mesmo com a URL digitada direto.
  • [x] CA11 (negativo — autorização). Dado ANALYST/OPERATOR/VIEWER, quando abrir a tela nova, então veem a lista sem os botões "Registrar pagamento"/"Marcar inadimplente".
  • [x] CA12 (isolamento). Dado GET /api/v1/billings/{id} de uma cobrança de outro tenant, então a resposta é 404.
  • [x] CA13 (mobile, 375px). Dado o viewport de 375px, quando abrir "Cobranças", então a lista aparece em RecordCard, o filtro de Competência não aparece, e tocar no card (fora dos botões de ação) navega para a garantia.
  • [x] CA14 (estado vazio). Dado um tenant sem nenhuma cobrança, quando abrir "Cobranças", então o texto explica que elas nascem automaticamente na assinatura, sem oferecer "criar cobrança".
  • [x] CA15 (link da Central de Operações). Dado o alerta "Cobranças vencidas críticas", quando clicar, então a tela nova abre já filtrada por status=OVERDUE; e um item da fila de trabalho (link por id da cobrança) redireciona para a garantia correspondente.
  • [x] CA16 (negativo — autorização, RN9). Dado ADMIN_GLOBAL, quando chamar POST .../billings/{id}/pay ou .../mark-overdue em qualquer cobrança, de qualquer tenant, então a API recusa com 403 — e, na tela (própria ou embutida na garantia), os botões "Registrar pagamento"/"Marcar inadimplente" não aparecem para esse papel.
  • [x] CA17 (RN1, omitido). Dado um tenant com allowed_due_days = [10], quando emitir uma garantia sem billing_due_day no corpo, então a emissão é aceita e a garantia grava billing_due_day = 10 — o contrato de hoje, sem o campo, continua funcionando.
  • [x] CA18 (RN4 estreitada). Dado uma garantia vigente com uma cobrança extra lançada via POST .../billings (origin = MANUAL) com vencimento futuro, quando encerrar a garantia, então essa cobrança continua PENDING e pagável — só as origin = CALENDAR viram CANCELLED (CA6).

14. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — Guarantee.billing_due_day (model) + migration Alembic
  • [x] apps/api — GuaranteeCreate/GuaranteeResponse com billing_due_day; RN1 em GuaranteeService.create
  • [x] apps/api — RN2 (geração do calendário) em GuaranteeService.register_signature
  • [x] apps/api — Billing.origin (model) + migration Alembic; RN4 (cancelamento futuro, só origin = CALENDAR) em GuaranteeService.terminate
  • [x] apps/api — BillingRepository/BillingService cross-garantia; router GET /billings, GET /billings/{id}, GET /billings/reference-months; permissão billings:list_all no rbac_seed.py
  • [x] apps/api — RN9: billings:update sai da concessão de ADMIN_GLOBAL em rbac_seed.py
  • [x] apps/api — docstrings + Swagger; testes (feliz + negativos da seção 13)
  • [x] apps/web — campo de dia de vencimento em GuaranteeCreateDialog.tsx
  • [x] apps/web — módulo pages/billings/ (desktop e mobile), rota guardada por billings:list_all, nav-groups.tsx sem comingSoon
  • [x] apps/web — pré-preenchimento de multa em BillingPayDialog; correção do ConfirmDialog de BillingList.tsx; correção RN9 do bypass de ADMIN_GLOBAL em BillingList.tsx
  • [x] apps/e2e — billings.spec.ts (sem seed JSON próprio — decisão registrada no §18: uma garantia é composta demais para SeedFile<T>), inclusive CA13 (375px)
  • [x] Suíte verde nas duas partes que make test roda em sequência — make test-web (132 testes) direto no host, pytest (794 testes, exceto os dois intermitentes conhecidos) via docker exec garantora-devremote-api-1 uv run pytest, porque esta máquina não tem uv no host (CLAUDE.md, "Estado do ferramental"). make lint (ruff, também via container) sem apontamento novo nas linhas que esta branch acrescentou — conferido achado a achado contra main: os 37 do guarantee_service.py/ billing_service.py já existiam antes, só mudaram de linha.
  • [x] Validado no browser em desktop e mobile — screenshot contra o dev-remote reconstruído, scrollWidth - clientWidth == 0 nos dois (o e2e limpa os próprios dados ao final, então a captura mostra o estado vazio, já conferido por teste; o objetivo aqui é layout, não dado)

15. Decisões em aberto

Nenhuma. Todas as decisões de negócio (gatilho da geração, dia de vencimento, cancelamento ao encerrar, multa automática, permanência da criação manual, acesso da imobiliária) foram tomadas na entrevista e nas rodadas de revisão das Partes I e II — ver seções 1-8. A única decisão de desenho tomada nesta Parte III sem confirmação explícita prévia é o precedente de ADMIN_GLOBAL ver GET /billings cross-tenant (10.5, herdado de GET /guarantees) — registrado ali, não uma pergunta em aberto.

16. Revisão do detalhamento

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 1 1 0 Reprovado
2 0 2 2 Reprovado
3 0 0 1 Aprovado

Resultado: aprovada na rodada 3.

A rodada 1 achou um risco de desenho real (Critical, S5): billings:update é concedido a ADMIN_GLOBAL pela regra geral do RBAC, e o front tinha um bypass explícito que liberava os botões de escrita para esse papel. RN9 foi acrescentada para fechar isso (permissão, front e teste), com CA16 cobrindo o negativo. O Important do menu (billings:list em vez de billings:list_all) foi corrigido junto.

A rodada 2 rodou o teste de verdade e achou que a narrativa de RN9 superestimava o risco atual: a escrita não chega a gravar hoje, porque BillingService._get_billing filtra por current_user.tenant_id diretamente (sem o padrão "None = sem filtro"), e ADMIN_GLOBAL (tenant None) não bate com nenhuma cobrança real — 404 hoje, não sucesso. É proteção por acidente de implementação, não por desenho, e por isso RN9 continua correta como correção preventiva; só a alegação de "já explorável hoje" foi reescrita para "protegido por um detalhe que pode mudar por outro motivo, sem relação com esta spec". Achados menores da mesma rodada: a matriz 10.1 dizia que ADMIN_GLOBAL "já podia" emitir/assinar — falso, as duas ações já são recusadas hoje (PLATFORM_ADMIN_CANNOT_EMIT/PLATFORM_ADMIN_CANNOT_AMEND, specs 0039/0040); a rota de leitura de allowed_due_days estava citada como GET .../onboarding/settings, quando é GET /api/v1/settings; e o CA9 dizia "garantias" em vez de "cobranças".

A rodada 3 rodou o teste de novo, confirmou que a narrativa de RN9 ficou exata, e achou só uma citação de linha deslocada (guarantee_service.py:291-292 → :294, o tenant_scope(current_user) de GuaranteeService.list), corrigida.

Riscos aceitos: nenhum.

17. Revisão da implementação

Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.

Rodada Escopo Critical Important Minor Veredicto
1 apps/api 0 2 0 REPROVADO
2 apps/api 0 1 0 REPROVADO
3 apps/api 0 0 1 APROVADO
4 apps/web 0 0 1 APROVADO
5 apps/e2e 0 1 0 REPROVADO
6 apps/e2e 0 1 1 REPROVADO
7 apps/e2e 0 0 0 APROVADO
8 branch — — — —

apps/e2e, três rodadas. A rodada 6 (Important CA15 + Minor do RN2) achou o que uma passada geral costuma achar depois de uma rodada focada num achado só: a rodada 5 tinha julgado só o CA13, e a rodada 6, ao varrer o resto do diff, achou o alerta "Cobranças vencidas críticas"/link da fila de trabalho (CA15) sem teste nenhum, e uma asserção de contagem fraca demais em RN2. Corrigido com um test.describe novo ("os links da Central de Operações") cobrindo os três casos de CA15 — status=OVERDUE pela URL sem tocar no filtro, redirecionamento para a garantia, e o caminho de cobrança inexistente caindo de volta em /billings sem travar — e a contagem de RN2 virou toBe(12), a conta exata para 12 meses com charge_first_month_upfront=true (o default do seed de e2e). A rodada 7 conferiu as duas correções linha a linha e não achou nada novo. Os onze testes de billings.spec.ts mais os cinco specs existentes tocados por componente compartilhado somam, juntos, contra o dev-remote reconstruído: 51 de 51 verdes (os 8 já contados antes do CA15, mais os 3 novos).

apps/api, três rodadas — as duas primeiras reprovaram por lacuna de cobertura, não de código:

  • Rodada 1 (2 Important): nenhum teste positivo confirmava que ANALYST/OPERATOR/ VIEWER alcançam GET /billings (só ADMIN_TENANT/ADMIN_GLOBAL eram testados); e os filtros de GET /billings (status, reference_month incluindo sem-competencia, search) não eram exercitados por teste nenhum.
  • Rodada 2 (1 Important): as duas correções da rodada 1 entraram (test_analyst_operator_viewer_alcancam_lista_agregada, test_filtros_da_lista_cross_garantia), mas o teste de search não discriminava — só havia uma garantia no tenant do teste, então qualquer termo batia com todas as cobranças e a asserção passaria mesmo sem o filtro. Corrigido acrescentando uma segunda garantia no mesmo tenant (inquilino "Zeca Outro") para provar que a busca exclui o que não bate.
  • Rodada 3 (1 Minor, aceito e corrigido mesmo assim): a guarda _garantir_que_nao_e_o_saas_admin (RN9) tinha teste negativo só para pay/mark_overdue; create/update (mesma tabela 10.2) não tinham teste próprio, embora fosse a mesma função compartilhada. Como o custo era baixo, os dois casos foram acrescentados ao mesmo teste (test_admin_global_nao_paga_nem_marca_inadimplente) em vez de virar pendência aceita.

Migration extra não prevista na Parte III original: Billing.origin (RN4 estreitada, ver §18) — coberta pela revisão de rodada 3 junto com o restante do diff.

Adição pós-aprovação: GET /billings/reference-months (ver §18) entrou depois da rodada 3, para fechar uma lacuna descoberta ao iniciar apps/web — a Parte II não tinha fonte de dado para o filtro de Competência. Pequena, segue o mesmo padrão de porteiro e escopo dos dois endpoints já revisados, e tem teste próprio (feliz + 403). Não abriu rodada 4 dedicada — a revisão da branch inteira, ao final, cobre este trecho.

Resultado: apps/api aprovada na rodada 3.

apps/e2e, duas rodadas. A rodada 5 achou um Important real: CA13 (o comportamento em 375px — RecordCard, filtro de Competência escondido, toque no card navega) não tinha teste em lugar nenhum do diff, embora o próprio teste unitário do apps/web já dissesse explicitamente que devolvia geometria para o e2e. Corrigido acrescentando um test.describe com viewport: { width: 375, height: 812 }, no mesmo padrão de credit-analyses-mobile.spec.ts. A rodada 6 confirmou a correção e não achou nada novo. Os oito testes de billings.spec.ts (incluindo o de 375px) mais os cinco specs existentes que exercitam componente compartilhado alterado (emissao-de-garantia-na-analise.spec.ts, emissao-e-assinatura-da-garantia.spec.ts, menu-por-permissao.spec.ts, mobile-shell.spec.ts — corrigido para não depender mais do comingSoon que "Cobranças" perdeu —, dossie-da-garantia-fechado.spec.ts) rodaram contra o dev-remote reconstruído, juntos, depois do CA13: 48 de 48 verdes.

apps/web, aprovada na primeira rodada. Um Minor: isAdminGlobal desestruturado de useCurrentUser() em BillingList.tsx e sem uso depois que o bypass saiu de podeEscrever (a própria correção da RN9) — resto morto, corrigido na hora. Conferidos ponto a ponto: o bypass de ADMIN_GLOBAL saiu de BillingList.tsx e a tela nova nunca o teve; /billings guardada por billings:list_all em App.tsx e nav-groups.tsx; o FilterSelect de Competência só existe no desktop; os dez data-testid da §11 batem com o texto, e os dois reaproveitados (billing-pay-{id}/billing-overdue-{id}) não mudaram de nome; o redirect de /billings/{id} resolve guarantee_id e cai em /billings só na cobrança inexistente/de outro tenant; a multa em BillingPayDialog só pré-preenche em OVERDUE ou PENDING vencida, com .../pay aceitando qualquer amount_paid como antes.

Defeitos aceitos: nenhum — o único Minor da rodada 3 foi corrigido em vez de aceito como pendência.

18. Registro

Anotações durante a implementação: o que mudou em relação ao planejado e por quê. Preencher com /spec implement.

  • Ações de linha viraram ícone, não texto. A tela nasceu com <Button>Registrar pagamento</Button>/<Button>Marcar inadimplente</Button> na coluna Ações do desktop — achado do usuário testando manualmente: toda outra lista do app (Imobiliárias, Garantias) usa IconButton, e Cobranças destoava. Corrigido para IconButton+Tooltip com os ícones já usados no mobile (check-circle/warning), sem size explícito — o padrão de Imobiliárias é o default de 20px, e só a lista embutida na garantia usa size menor por falta de espaço. Registrado em docs/ui-patterns.md, "Tabelas — Padrão", para não se repetir em tela nova: ações de linha são sempre ícone, com Tooltip levando o rótulo que o texto carregava. data-testid não mudou. Confirmado com os 11 testes de billings.spec.ts (verdes) e captura visual comparando com Imobiliárias, lado a lado, no dev-remote reconstruído.
  • Achado fora do escopo, corrigido nesta branch por pedido do usuário. Depois da implementação, /journeys (spec 0026) recapturou as três jornadas de análise de crédito (analista, gestor, imobiliária) para conferir se o dev-remote reconstruído ainda as reproduzia. Achou dois problemas na própria ferramenta — não no produto —, corrigidos em journeys/dados.js e nos quatro mapas (analise-credito-*.json, imobiliaria.json): o gerador de CPF e os telefones mascarados, que _validate_cpf/ AppPhone (apps/api/app/schemas/common.py) recusam desde sempre, sem relação com esta spec; e a falta de pendency_type_id, obrigatório desde a spec 0037 (RN9) em POST .../decision/pend, que os mapas nunca haviam enviado.

Um terceiro achado, esse no produto: no passo "Corrigindo o que foi pedido" da jornada da imobiliária (/credit-analyses/{id}/edit), o console acusava 403 reproduzível nos dois viewports — CreditAnalysisForm.tsx sempre chamava GET /partner-agencies na carga inicial, mesmo para AGENCY_USER, que nunca tem partner_agencies:list (spec 0034) e sempre toma 403 ali. O tolerate403 (spec 0036) já evitava o redirecionamento para /unauthorized, mas não evitava a chamada de rede em si — o navegador loga o 403 no console de qualquer jeito, e nenhum .catch() suprime isso. Corrigido pulando a chamada de vez quando ehImobiliaria (o bloco de imobiliária, que já não existe na tela dela) é verdadeiro, resolvendo direto para [] — mesmo resultado que o .catch() já produzia, sem o round-trip. Teste novo em CreditAnalysisForm.test.tsx (AGENCY_USER não busca /partner-agencies), e a recaptura da jornada confirmou 0 defeito(s) depois do rebuild. Decisão do usuário: corrigir nesta branch em vez de abrir um /fix separado, apesar de a spec 0048 não tocar em análise de crédito. Regressão confirmada com os dois specs e2e existentes que exercitam a mesma tela — retorno-analise-imobiliaria.spec.ts (inclusive o fluxo de edição da imobiliária) e credit-analyses-form-steps.spec.ts — 25 de 25 verdes contra o dev-remote reconstruído. - RN1 tornado opcional. Ver nota já registrada na própria RN1 (§9): tornar billing_due_day obrigatório em POST /guarantees quebraria o contrato para ~6 arquivos de teste existentes; ficou int | None, com default seguro (allowed_due_days[0]). - RN4 estreitada para origin = CALENDAR. A primeira implementação cancelava toda Billing PENDING com vencimento futuro ao encerrar a garantia, sem olhar a origem — exatamente como a redação original da RN4 dizia. Rodando a suíte de regressão dos seis arquivos que emitem garantia, isso quebrou test_guarantee_signature.py::test_cobranca_so_em_garantia_vigente (spec 0038, RN13): o teste lança uma cobrança manual com vencimento futuro, encerra a garantia e espera continuar podendo pagá-la — "dívida emitida não some porque a garantia acabou". A RN4, como escrita, cancelava essa cobrança antes da tentativa de pagamento, e o pay passava a falhar com BILLING_ALREADY_SETTLED.

Duas saídas possíveis: (a) estreitar a RN4 para só atingir o calendário automático da RN2, deixando a cobrança manual (RN6) imune; ou (b) manter a RN4 como estava e aceitar que cobrança manual futura também é varrida no encerramento, ajustando o teste antigo. Decisão do usuário: (a). Isso exigiu um campo novo — Billing.origin (CALENDAR | MANUAL) — e uma segunda migration, não previstos na Parte III original. RN4 e RN6 foram reescritas no §9, o contrato da §12 ganhou o campo origin e a segunda migration, e o CA18 cobre o comportamento estreitado. Testes: test_encerrar_nao_cancela_cobranca_manual_futura (novo, RN4 estreitada) e o retorno ao verde de test_cobranca_so_em_garantia_vigente (regressão original). - Endpoint novo, não previsto no §12/§11 originais: GET /api/v1/billings/reference-months. A Parte II (7.1) prometia o FilterSelect de Competência populado pelos "valores distintos de reference_month existentes no tenant", mas nenhuma seção do detalhamento dava uma fonte de dado para essa lista — só os dois endpoints de leitura cross-garantia (lista e detalhe) tinham sido especificados. Duas saídas possíveis: (a) um endpoint novo e pequeno (SELECT DISTINCT, correto em qualquer escala); ou (b) aproximar no apps/web, buscando uma página grande de cobranças e derivando os meses do que veio, incompleto quando o tenant passa do tamanho da página sem cobrir todos os meses. Decisão do usuário: (a). Mesma permissão (billings:list_all), sem RBAC novo. Registrado no router antes de /{billing_id}, para não ser capturado pela rota de detalhe. Teste: test_meses_de_competencia_distintos (feliz, exclui a cobrança sem competência) e o 403 de AGENCY_USER acrescentado a test_agency_user_nao_alcanca_lista_agregada.

Afirmações conferidas

Fatos sobre o repositório que uma rodada de portão já conferiu abrindo o arquivo. O recortar.py leva esta seção junto em qualquer parte, e o revisor da rodada seguinte pode tomá-los como dados em vez de reconferir tudo do zero.

Afirmação Onde na spec Conferida em

Duas regras, e a segunda é a que faz a primeira valer:

  1. Só entra o que um revisor conferiu — não o que você conferiu ao escrever. O ponto é aproveitar trabalho já pago por uma rodada, não abrir atalho para afirmar sem prova.
  2. Mexeu na frase da spec, a linha sai da tabela. A afirmação volta a ser conferível. Sem isso a tabela vira o oposto do portão: uma afirmação carimbada como verdadeira porque um dia foi.

Feature sem afirmação sobre o repositório não precisa da seção — apague-a.