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.
- 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.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- 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.
- 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:
- Uma garantia é emitida e depois assinada — vira
ACTIVE(o único ato que libera cobrança, por causa da RN12 da spec 0038:BillingService.createrecusa criarBillingem garantia que não estáACTIVE). - 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 (componenteBillingList), e clica em "Nova cobrança" (BillingCreateDialog). - 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. - 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. - Se o pagamento atrasa e ainda não foi pago, uma ação separada e também manual (
mark-overdue) muda o status paraOVERDUE— não há nenhuma rotina no sistema que faça essa transição sozinha; hoje ela só acontece se alguém lembrar de clicar. - Se a garantia é encerrada antes do fim do prazo (
TERMINATED), nada cancela as cobranças futuras que porventura já existissem comoPENDING— elas continuam lá, sem dono, a não ser que alguém lembre de tratá-las à mão. - 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çasOVERDUEhá mais de 7 dias, num alerta e numa fila de prioridade — mas sóOVERDUE, nãoPENDING(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=OVERDUEe/billings/{id}, rotas que hoje não levam a lugar nenhum porque a tela/billingsestá marcadacomingSoon(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) —
Billingcontinua 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
OVERDUEsozinha quando a data vence — não existe hoje nenhum agendador no sistema, e esta spec não introduz um. A transição paraOVERDUEcontinua 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á
ACTIVEantes 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_daysdo 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 nascePENDING_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 aACTIVE), 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, hojecomingSoon) — a rota/billingspassa 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âmetrostatusda 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 emApp.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.
Guaranteeganha o campobilling_due_day(inteiro), aceito emPOST /guarantees(GuaranteeCreate, hoje sem esse campo —schemas/guarantee.py:18-43). Opcional no schema (int | None, defaultNone) — 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 dePOST /guaranteespara todo chamador existente por um campo que tem default seguro. Omitido, usa o primeiro valor deTenantSettings.allowed_due_daysdo tenant (tenant_settings.py:43-45, hoje só exposto como leitura viaGET /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ão422(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 emGuaranteeService.create. - RN2 — Geração automática do calendário completo na assinatura. Quando
GuaranteeService.register_signaturemuda o status paraACTIVE(guarantee_service.py:629), na mesma transação (antes doaudit.logde:634-641, depois doself.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 debilling_due_daya partir de (e incluindo)start_date,reference_month= o mês dessa data (YYYY-MM),status = PENDING. - Cobranças mensais:
amount = guarantee.monthly_valuecada, cobrindo os meses do prazo que a cobrança de setup não inclui. A contagem de meses vem demeses_de_vigencia(start_date, end_date)(domain/periodo.py:61-62); secharge_first_month_upfrontfor verdadeiro (pricing.py:76, é o que faztotal_upfrontjá incluir o 1º mês), a primeira mensal é a do 2º mês; senão, a do 1º. As datas de vencimento vêm desomar_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), incrementandoka cada mensal. - Todas nascem
status = PENDING, semnotes. - RN3 — RN12 (spec 0038) permanece intacta.
BillingService.createcontinua recusandoBillingfora deACTIVE(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.terminatemuda o status paraTERMINATED(guarantee_service.py:576), na mesma transação (junto aoself.repo.savede:580) todaBillingda garantia comorigin = CALENDAR,status = PENDINGedue_dateestritamente posterior à data do encerramento passa aCANCELLED. Restrita aorigin = CALENDAR— divergência da redação original, que não distinguia origem e cancelava qualquerPENDINGfutura; 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.Billingganha o campoorigin(CALENDAR|MANUAL,models/billing.py) para sustentar essa distinção —CALENDARpara o que a RN2 gera,MANUALpara o queBillingService.createlança (default do model, e o que toda cobrança anterior a esta spec recebe viaserver_default). Cobrança já vencida (PENDINGcomdue_dateno passado) ou jáOVERDUEnã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, ouPENDINGcomdue_dateno passado, o valor pré-preenchido (editável) passa a seramount × (1 + late_fee_percent / 100)deTenantSettings(hoje nunca lido embilling_service.py— Parte I, seção 1), arredondado a duas casas. O cálculo é feito noapps/web; o contrato dePOST .../billings/{id}/paynão muda — continua aceitando qualqueramount_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_monthcontinua opcional aí (apps/api/app/schemas/billing.py:33). Gravaorigin = 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
Billingfora do escopo de uma garantia —GET /guarantees/{id}/billingsé por garantia (billing_repo.py:14-24). A tela nova (Parte II, 7.B) precisa deGET /api/v1/billings(lista, filtros destatus,reference_monthe busca) eGET /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_monthjá é opcional emBilling(RN6). O filtro de Competência deGET /api/v1/billingsaceita o valor sentinelasem-competenciapara alcançar as cobranças sem esse campo — sem ele, ficariam invisíveis para qualquer filtro de mês. - RN9 —
ADMIN_GLOBALnã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:49já libera "Registrar pagamento"/"Marcar inadimplente" paraADMIN_GLOBALpor um bypass explícito (isAdminGlobal || (user?.permissions ?? []).includes("billings:update")), e orbac_seed.py:156-157concedebillings:updatea 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 porcurrent_user.tenant_iddiretamente, sem o padrão "None= sem filtro" quetenant_scope/_get_guaranteeusam —ADMIN_GLOBALtemtenant_id = None, eBilling.tenant_id == Nonenão bate com nenhuma cobrança real, então a chamada devolve404mesmo hoje, mesmo com os dois UUIDs certos em mãos. É proteção por acidente, não por desenho: um dia em que_get_billingfor 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:updateentra na subtração de_ROLE_PERMISSIONS["ADMIN_GLOBAL"]emrbac_seed.py:157, ao lado deguarantees:dossier_attach/guarantees:dossier_amend(mesmo padrão da spec 0040). O bypass deBillingList.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 aADMIN_GLOBAL— é só a escrita que fecha (10.5 explica por quê a leitura segue o precedente deguarantees: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_daynão é FK — é um inteiro validado contraTenantSettingsdo próprio tenant do solicitante (RN1), nunca aceito de outro tenant.GET /api/v1/billingseGET /api/v1/billings/{id}filtram portenant_idno banco, sem exceção — mesmo padrão deGuaranteeService.list(guarantee_service.py:294,tenant_scope(current_user),from app.services.scoping import tenant_scope). Cobrança de outro tenant emGET /billings/{id}→ 404.- A RN2 e a RN4 não recebem FK nova nenhuma — operam sobre a própria
Guaranteejá resolvida no escopo porregister_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— semNOT NULL(RN1), então sem necessidade de valor default para linhas existentes.downgrade:DROP COLUMN. - Schemas:
GuaranteeCreate(schemas/guarantee.py:18-43) ganhabilling_due_day: int = Field(..., description=...).GuaranteeResponseganha o mesmo campo, para oapps/webexibir/reenviar se precisar. Novos:BillingListItem/BillingListResponse(paginado, mesmo formato dePaginatedResponsequeGuaranteeService.listjá usa) paraGET /api/v1/billings. - Endpoints (permissão — seção 10.2):
POST /guarantees— sem mudança de rota,GuaranteeCreatecom o campo novo.GET /api/v1/billings— novo,billings:list_all. Filtros:status,reference_month(aceitasem-competencia),search(nome do inquilino ou nº da garantia). Paginado.GET /api/v1/billings/{billing_id}— novo,billings:list_all. Resposta incluiguarantee_id, para oapps/webresolver o redirecionamento (RN7).GET /api/v1/billings/reference-months— novo,billings:list_all. Decisão tomada na implementação (§18): a Parte II prometia oFilterSelectde 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— validabilling_due_daycontraTenantSettings.allowed_due_days(RN1).GuaranteeService.register_signature— gera o calendário completo deBillingapósself.repo.save(guarantee)(RN2), usandoBillingRepositorypara persistir em lote na mesma transação (CommitRoutejá garante atomicidade).GuaranteeService.terminate— cancelaBillingPENDINGfutura (RN4).BillingServiceganhalist_all/get_by_id_cross_guarantee(ou nome equivalente), reaproveitandotenant_scope/agency_scopedeservices/scoping.py— mesmo padrão deGuaranteeService.list(10.3).BillingRepositoryganha os métodos de consulta cross-garantia (filtro por status,reference_month, busca por nome do inquilino viajoinemGuarantee/tomador — o mesmojoinqueGuaranteeService.listjá faz para a busca por nome do tomador).- RBAC:
app/db/rbac_seed.pyganha a permissãobillings:list_allna lista_PERMISSIONSe nos conjuntos deADMIN_GLOBAL,ADMIN_TENANT,ANALYST,OPERATOR,VIEWER— não no deAGENCY_USER(10.2). E a subtração de_ROLE_PERMISSIONS["ADMIN_GLOBAL"](rbac_seed.py:157) ganhabillings:update, ao lado deguarantees:dossier_attach/guarantees:dossier_amend(RN9). - Sem mudança:
app/services/operations_center_service.py— osaction_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 comcharge_first_month_upfrontverdadeiro e falso (números de cobrança e datas diferentes), RN3 (assinar sembilling_due_daypreenchido — garantia antiga — não gera nada, não quebra), RN4 (cobrança futura cancela, cobrança vencida não),GET /billingscross-tenant (outro tenant não aparece),GET /billings/{id}de outro tenant → 404,AGENCY_USERemGET /billings→ 403, e RN9:ADMIN_GLOBALem.../billings/{id}/paye.../mark-overdue→ 403 pela permissão — o teste que fixa a proteção por desenho, para não depender do detalhe acidental de_get_billingque a blindava até aqui.
apps/web¶
GuaranteeCreateDialog.tsx(credit-analyses/components/) — campoSelectnovo, dia de vencimento, populado porTenantSettings.allowed_due_days(já disponível viaGET /api/v1/settings, sem chamada nova). Entra na pilha do blocoformulario(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). ReaproveitaBillingPayDialog, oConfirmDialogde "Marcar inadimplente" (já existentes empages/guarantees/components/, importados, não copiados) e oRecordCarddecomponents/shared/RecordCard.tsxpara o layout mobile. - Correção incluída:
BillingList.tsx(a lista embutida na garantia) passa a passarerroraoConfirmDialogde "Marcar inadimplente" — hoje esse erro vai para oAlertda 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:49perde o bypassisAdminGlobal ||— a condição depodeEscreverpassa 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" trocarequerPermissao: "billings:list"porrequerPermissao: "billings:list_all", para o menu refletir a permissão que de fato governa a rota nova (10.1) em vez de depender só doocultoParaPapeljá existente para esconder doAGENCY_USER.data-testidnovos: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 emBillingList.tsx). Acrescentados na implementação, não previstos aqui:billing-pay-dialog/billing-pay-amount-input/billing-pay-confirm/billing-pay-cancel/billing-pay-errornoBillingPayDialog— o diálogo é de antes desta spec e nunca tinhadata-testidnenhum, porque nenhum e2e o exercitava; oapps/e2edesta spec é o primeiro a clicar em "Registrar pagamento" de ponta a ponta, e a convenção doapps/e2e/CLAUDE.md("todo elemento pordata-testid, nunca CSS/texto") exigia o atributo para existir.- Sider:
nav-groups.tsx:171— removecomingSoon: truedo item "Cobranças". Sem mudança emcustom-sider/index.tsx— o sider já lê os itens denav-groups.tsxdiretamente (confirmado nesta spec: não há uma segunda lista hardcoded a manter). App.tsx—resources[]e<Route path="/billings">novos, guardados porExigeAcesso permissao="billings:list_all"— nãobillings:list: é essa permissão, não o menu, que barra oAGENCY_USERdigitando 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_USERnão vê "Cobranças" no menu e recebe bloqueio ao digitar/billings. - Seed:
tests/fixtures/seeds/— uma garantiaACTIVEcombilling_due_daydefinido 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):
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 combilling_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 com422 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) emeses_de_vigencia − 1cobranç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 emeses_de_vigenciacobranç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
PENDINGcom vencimento futuro viraCANCELLED, e nenhumaOVERDUEouPENDINGjá 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 delate_fee_percentjá 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 eGET /api/v1/billingsdevolve403mesmo 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 poridda cobrança) redireciona para a garantia correspondente. - [x] CA16 (negativo — autorização, RN9). Dado
ADMIN_GLOBAL, quando chamarPOST .../billings/{id}/payou.../mark-overdueem qualquer cobrança, de qualquer tenant, então a API recusa com403— 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 sembilling_due_dayno corpo, então a emissão é aceita e a garantia gravabilling_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 continuaPENDINGe pagável — só asorigin = CALENDARviramCANCELLED(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/GuaranteeResponsecombilling_due_day; RN1 emGuaranteeService.create - [x]
apps/api— RN2 (geração do calendário) emGuaranteeService.register_signature - [x]
apps/api—Billing.origin(model) + migration Alembic; RN4 (cancelamento futuro, sóorigin = CALENDAR) emGuaranteeService.terminate - [x]
apps/api—BillingRepository/BillingServicecross-garantia; routerGET /billings,GET /billings/{id},GET /billings/reference-months; permissãobillings:list_allnorbac_seed.py - [x]
apps/api— RN9:billings:updatesai da concessão deADMIN_GLOBALemrbac_seed.py - [x]
apps/api— docstrings + Swagger; testes (feliz + negativos da seção 13) - [x]
apps/web— campo de dia de vencimento emGuaranteeCreateDialog.tsx - [x]
apps/web— módulopages/billings/(desktop e mobile), rota guardada porbillings:list_all,nav-groups.tsxsemcomingSoon - [x]
apps/web— pré-preenchimento de multa emBillingPayDialog; correção doConfirmDialogdeBillingList.tsx; correção RN9 do bypass deADMIN_GLOBALemBillingList.tsx - [x]
apps/e2e—billings.spec.ts(sem seed JSON próprio — decisão registrada no §18: uma garantia é composta demais paraSeedFile<T>), inclusive CA13 (375px) - [x] Suíte verde nas duas partes que
make testroda em sequência —make test-web(132 testes) direto no host,pytest(794 testes, exceto os dois intermitentes conhecidos) viadocker exec garantora-devremote-api-1 uv run pytest, porque esta máquina não temuvno 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 contramain: os 37 doguarantee_service.py/billing_service.pyjá existiam antes, só mudaram de linha. - [x] Validado no browser em desktop e mobile — screenshot contra o dev-remote reconstruído,
scrollWidth - clientWidth == 0nos 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/VIEWERalcançamGET /billings(sóADMIN_TENANT/ADMIN_GLOBALeram testados); e os filtros deGET /billings(status,reference_monthincluindosem-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 desearchnã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ó parapay/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) usaIconButton, e Cobranças destoava. Corrigido paraIconButton+Tooltipcom os ícones já usados no mobile (check-circle/warning), semsizeexplícito — o padrão de Imobiliárias é o default de 20px, e só a lista embutida na garantia usasizemenor por falta de espaço. Registrado emdocs/ui-patterns.md, "Tabelas — Padrão", para não se repetir em tela nova: ações de linha são sempre ícone, comTooltiplevando o rótulo que o texto carregava.data-testidnão mudou. Confirmado com os 11 testes debillings.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 emjourneys/dados.jse 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 dependency_type_id, obrigatório desde a spec 0037 (RN9) emPOST .../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:
- 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.
- 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.