Pular para conteúdo

0054 — Cobrança com tipo, canal e parcela, e o resumo do mês que hoje mora na planilha

Status CONCLUÍDA
Branch spec/0054-cobranca-por-tipo-e-canal
Criada em 2026-09-20
Apps afetados api · web · e2e
Revisão do problema Aprovada (rodada 4)
Revisão da experiência Aprovada (rodada 5)
Revisão do detalhamento Aprovada (rodada 6)
Revisão da implementação Aprovada — por fase (api ×4, web ×6, e2e) e da branch inteira (rodada 2, nota 8,5)

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

Existe um relatório mensal real, fora do produto: a planilha "Cobrança e Pagamento — Solar Imóveis", mantida por um cliente-piloto (a garantidora TXC Garantia) para a imobiliária parceira Solar Imóveis. Uma aba por mês e por imobiliária; cada linha é uma parcela de um contrato, com Setup, Fiança, Boleto TXC, Solar, Parcelas (5/12), Vencimento, Multa e o bloco de Pagamento (colunas K–O); ao pé, "Previsão de faturamento", "Pagamentos efetuados" e "Acerto Solar".

A spec 0048 pôs a cobrança dentro do produto — o calendário nasce sozinho na assinatura (apps/api/app/services/guarantee_service.py:705-747) e existe uma tela que lista as cobranças de todas as garantias. Mesmo assim a planilha continua sendo mantida. Três causas se somam, e a primeira é que três das informações de que o relatório é feito não existem como dado no sistema:

  • Tipo — setup ou fiança. A separação existe na garantia: initial_fee e first_month_upfront são colunas gravadas no snapshot da emissão (apps/api/app/models/guarantee.py:205-206) e o detalhe da garantia já as mostra como "Taxa de setup" e "1ª mensalidade" (apps/web/src/pages/guarantees/components/GuaranteeSections.tsx:120-121). O que não existe é a separação na cobrança: a parcela do primeiro mês nasce com amount = guarantee.total_upfront (apps/api/app/services/guarantee_service.py:723-732), um valor só. Responder "quanto de setup entrou nesta competência" é, então, abrir garantia por garantia, ler os dois campos e somar à mão — o dado não está na linha que a competência filtra, e por isso não se agrupa nem se soma por mês.

E nem todo primeiro mês é composto. charge_first_month_upfront é parâmetro da garantidora (apps/api/app/models/tenant_settings.py:102, padrão ligado), congelado na garantia no ato da emissão (guarantee.py:203). Ligado, first_month_upfront = monthly_value e a entrada soma as duas coisas; desligado, ele é zero (apps/api/app/services/pricing.py:76-77) e a primeira cobrança é setup puro, com as mensais começando já no mês 1 (guarantee_service.py:735). As duas configurações produzem hoje a mesma linha indistinta na lista — não dá para saber, olhando, qual delas está em vigor. - Canal — quem recolhe o dinheiro. Boleto emitido pela garantidora, ou PIX recebido pela imobiliária e repassado depois. É a linha que separa dinheiro que já está na conta de dinheiro que ainda vai ser cobrado da parceira. O CLAUDE.md (Domínio, "Fluxo do dinheiro") fixa a regra, e sem exceção: setup e 1ª parcela em boleto no primeiro vencimento; do 2º mês em diante o inquilino paga à imobiliária, que repassa. As colunas "Boleto TXC" e "Solar" da planilha são essa mesma regra escrita parcela a parcela. Que o recolhimento das mensais varie de parceira para parceira — a Solar recolhe, outra pode não recolher — é generalização desta spec, não o que o documento de domínio diz hoje; a seção Domínio do CLAUDE.md fica devendo essa atualização, e quem for conferir a fonte não vai achar a metade variável lá. O sistema não registra nenhuma das duas metades. Não há campo para isso em lugar nenhum: nem em Billing (apps/api/app/models/billing.py:104-130), nem em Guarantee, nem em PartnerAgency (apps/api/app/models/partner_agency.py:23-43), nem em TenantSettings. Sem o registro, a regra do domínio é conhecimento de quem opera, não dado que se filtre ou se some.

"Canal" já significa outras duas coisas aqui, e este é um terceiro sentido. No domínio, a imobiliária é o canal (CLAUDE.md, Domínio). No código, ChannelType é EMAIL/WHATSAPP (apps/api/app/models/channel_integration.py:9-12), que é notificação. O sentido desta spec é por onde o dinheiro daquela parcela entra. E "origem", o sinônimo mais óbvio, também está ocupado: Billing.origin (billing.py:125, enum BillingOrigin em :51) já distingue a cobrança que o calendário gerou da lançada à mão. Os dois nomes candidatos estão tomados — fica registrado para a Parte III escolher com a colisão à vista. - Parcela n/N. Billing.reference_month (billing.py:121) diz de que mês é a parcela; não diz que ela é a 5ª de 12. Para saber, conta-se as cobranças da garantia uma a uma.

Sem essas três, não há como somar pelas dimensões de que o relatório é feito. A tela de Cobranças lista linha a linha e pagina, com as colunas Garantia, Inquilino, Imobiliária, Competência, Vencimento, Valor, Valor pago, Status e Ações (apps/web/src/pages/billings/index.tsx:434-442). Ela conta registros (index.tsx:256-257,284-285) e não soma dinheiro nenhum. Somar por tipo e por canal, que é o recorte entregue todo mês à imobiliária e à contabilidade, segue sendo trabalho de planilha.

E já existe uma soma dentro do produto, que diverge dessa. A Central de Operações — a página inicial do tenant (apps/web/src/App.tsx:160) — calcula previsão × realizado por mês: _monthly_financial soma previsto, recebido, em atraso e pendente sobre Billing (apps/api/app/services/operations_center_service.py:541-586), get_financial_summary os expõe (:333-352) e a home os pinta (apps/web/src/pages/operations-center/components/SummaryCards.tsx:185-186). É o mesmo par "Previsão de faturamento" / "Pagamentos efetuados" da planilha — sem as duas dimensões que faltam, e com outra definição de mês: a Central recorta por janela de due_date (operations_center_service.py:560-565), a tela de Cobranças filtra por reference_month. "Quanto nesta competência" já tem, portanto, duas respostas dentro do produto, que só coincidem quando vencimento e competência caem no mesmo mês. A divergência que a planilha cria contra o sistema já existe também dentro dele, e ninguém a concilia.

O que fica errado ou manual enquanto isso existe:

  • O relatório do mês é refeito à mão, por fora, e as duas bases divergem em silêncio: uma baixa lançada só num dos lados não aparece como divergência em lugar nenhum — não há nada que compare os dois.
  • O encargo de atraso é decidido fora do sistema, e o sistema não tem como chegar nele. O contrato da TXC prevê multa e juros de mora; a coluna "Multa" da planilha traz os dois já somados num valor só, feito na calculadora — é por isso que a planilha não tem coluna de juros separada. Do lado do produto existe um parâmetro só: TenantSettings.late_fee_percent (apps/api/app/models/tenant_settings.py:48-50), que alimenta um valor sugerido e editável no diálogo de pagamento, calculado na tela (apps/web/src/pages/guarantees/components/BillingPayDialog.tsx:32-33,42). Não há juros nem carência de mora no modelo (tenant_settings.py:40-80; o único grace ali, claim_grace_period_days, é de sinistro, não de cobrança). O contrato também concede uma carência antes de o encargo passar a correr, e ela não existe em campo nenhum — quem calcula precisa lembrar dela de cabeça, e errar tem dois sinais: para menos, cobra-se encargo de quem ainda estava dentro do prazo; para mais, perde-se o que era devido. Então o valor que o sistema sugere nunca é o que o contrato manda cobrar, e quem dá baixa o sobrescreve com o número que calculou por fora — todo mês, em toda parcela atrasada. Na lista, a cobrança vencida mostra o texto "+ multa" sem valor nenhum (index.tsx:494).
  • O atraso depende de alguém lembrar de clicar. A transição para OVERDUE é uma ação manual (apps/api/app/services/billing_service.py:156-173); não há rotina que a faça sozinha. A tela disfarça, calculando "em atraso" pela data no front (index.tsx:78-80) — mas o status gravado segue PENDING, e tudo que é servido pela API a partir dele (filtro por status, contagem, os alertas da Central de Operações) conta errado.

2. Quem sente

  • ADMIN_TENANT — o operador financeiro da garantidora. É quem monta o relatório do mês e quem dá as baixas; é o único papel de sistema com billings:update (apps/api/app/db/rbac_seed.py:280-281). Sente em todas as três frentes: a reclassificação manual de cada linha, a multa na calculadora e o clique do atraso. Caminho obrigatório, não exceção: toda garantia ativa gera uma cobrança por mês, e toda competência produz um relatório — o trabalho se repete por competência, para sempre, enquanto durar a carteira.
  • ANALYST, OPERATOR e VIEWER leem a lista agregada (billings:list_all, rbac_seed.py:329,359,379) e não conseguem responder nenhuma das perguntas acima sem pedir a planilha a quem a mantém. Dor derivada, mais leve: não operam, mas dependem do mesmo número.
  • ADMIN_GLOBAL lê a lista de todos os tenants e não movimenta cobrança (RN9 da spec 0048); não é quem sente esta dor — o SaaS admin administra a plataforma, não opera dado de negócio do cliente.
  • AGENCY_USER não alcança a tela agregada: a permissão billings:list_all nunca lhe é concedida (rbac_seed.py:422-427), por decisão da spec 0038 (RN19a) reafirmada na 0048. Ele não está entre quem esta spec atende — ver seção 5.

Não há volume medível dentro do produto, e a razão é a própria dor. O CLAUDE.md ("Estágio do produto") registra produção no ar sem cliente; o piloto existe fora do sistema — a operação da TXC com a Solar roda na planilha, não no produto. Por isso não se cita aqui quantas parcelas por mês ou quantos reais: o número que existe está na planilha do cliente, não numa base que esta spec possa medir. O alcance vem do desenho, não da contagem.

3. A jornada de hoje

Do gatilho ao desfecho, com o que acontece dentro e fora do sistema.

  1. Dentro. A análise é aprovada, a garantia é emitida com um dia de vencimento e a assinatura é registrada. No instante da assinatura, o calendário inteiro nasce (guarantee_service.py:705-747, RN2 da spec 0048): uma cobrança de entrada com total_upfront no primeiro vencimento e uma de monthly_value por mês até o fim da vigência — começando no 2º mês quando a garantidora cobra a 1ª mensalidade junto da entrada, e no 1º quando não cobra (guarantee_service.py:735).
  2. Dentro. No começo do mês, o operador abre Cobranças (/billings), filtra pela competência e vê as parcelas — Garantia, Inquilino, Imobiliária, Competência, Vencimento, Valor, Valor pago, Status (index.tsx:434-442).
  3. Fora. Para montar o relatório do mês, ele abre a planilha da Solar, copia a aba do mês anterior e, linha por linha: avança o número da parcela (5/12 → 6/12), confere o vencimento, e reclassifica cada linha em Setup ou Fiança e em Boleto TXC ou Solar. Três dessas quatro informações não vêm da tela: duas não existem como dado (tipo e canal) e a terceira se conta à mão (a parcela). A quarta, o vencimento, é coluna da lista — ele a reconfere mesmo assim, porque não confia na aba que copiou.
  4. Dentro e fora. Quando o inquilino paga um boleto da garantidora, o operador dá baixa na tela (BillingPayDialog) e repete o lançamento na planilha, nas colunas K–O: canal, data, valor e multa.
  5. Só fora. Quando quem recebeu foi a Solar, não há pagamento a registrar no produto — o dinheiro está com a imobiliária, não com a garantidora. A linha fica PENDING na tela e é anotada na planilha, no bloco "Acerto Solar", como pendência de repasse. O produto não distingue, aqui, o inquilino que pagou em dia à imobiliária do que não pagou ninguém: os dois são a mesma linha pendente e vencida, e quem sabe a diferença é a Solar. É essa cegueira que faz o atraso do canal da imobiliária ser assunto do acerto, e não desta spec (seção 5).
  6. Dentro e fora. Se a parcela atrasou, a multa é calculada na calculadora e digitada na planilha. Na tela, o operador precisa lembrar de clicar "Marcar inadimplente" (billing_service.py:156-173); no diálogo de pagamento o valor sugerido já vem com a multa (BillingPayDialog.tsx:42), mas sem juros e sem carência, então ele corrige à mão o valor sugerido para bater com o que combinou.
  7. Só fora. No fechamento, os blocos "Previsão de faturamento" e "Pagamentos efetuados" da planilha somam por tipo e por canal. É essa aba — o arquivo da planilha — que vai para a imobiliária e para a contabilidade. A Central de Operações mostra, na home, uma soma do mês que se parece com a de cima, mas por outro recorte de data (seção 1), então ela não substitui a aba: os dois números não batem, e quem envia manda o da planilha.

Onde quebra. Em três pontos, e são causas diferentes — é por isso que nenhum conserto resolve os outros dois.

  • No passo 3, e o estrago aparece no 7. Como tipo e canal não são dado, cada competência é reclassificada manualmente, linha a linha; e como não há soma por essas duas dimensões, o número que sai para o cliente sai da planilha. O produto vira uma segunda base que ninguém concilia com a primeira: os passos 4 e 5 escrevem nos dois lugares (ou só num), e nada acusa quando divergem.
  • No passo 6, primeira metade — o encargo. Quem dá baixa numa parcela atrasada abre a calculadora: soma multa e juros sobre o valor, desconta a carência do contrato de cabeça, e sobrescreve o número que a tela sugeriu. O sistema não erra o cálculo — ele não faz o cálculo, e o que sugere é sempre menos do que o contrato manda cobrar.
  • No passo 6, segunda metade — o estado. O atraso é um clique. Enquanto ninguém clica, a lista mostra "em atraso" porque o front calcula pela data (index.tsx:78-80), mas o status gravado continua PENDING — então o filtro por status, a contagem e os alertas da Central de Operações, que leem o dado e não a tela, discordam do que a pessoa está vendo.

O que já se tentou.

  • A spec 0048, mesclada e em uso: tirou a criação manual do caminho e deu a lista agregada. Resolveu a metade "cadê a cobrança deste mês"; não tocou a metade "de que tipo, por qual canal, quanto no total" — e disse isso explicitamente, deixando "fechamento e repasse agregado por imobiliária" fora de escopo (docs/specifications/0048-gestao-de-cobrancas/specification.md:173-178).
  • A planilha, que é o contorno em uso — não um rascunho: é dela que sai o relatório entregue ao cliente hoje.
  • late_fee_percent, modelado antes da 0048 (tenant_settings.py:48-50) e sem nenhum uso até a 0048 o transformar em sugestão de tela. A multa efetivamente cobrada continua sendo decidida fora.
  • A Central de Operações, que é a tentativa mais próxima e a que mais ilustra o problema. Ela já faz as duas coisas que faltam — soma a competência (operations_center_service.py:541-586) e leva ao que venceu, com contagem e link pronto para a lista filtrada (:382-388, consumido por uma leitura de ?status= na montagem da tela — que esta spec remove, e cuja remoção o código registra em apps/web/src/pages/billings/index.tsx:178-180). E mesmo assim ninguém usa esses números no fechamento: a soma não separa tipo nem canal, recorta o mês por outra data, e a parte de atraso depende do status gravado — portanto do clique do passo 6. Não é que falte agregação no produto; é que a que existe não responde as perguntas do relatório, e diverge da outra.
  • Automatizar o atraso já foi cogitado e descartado uma vez. A 0048 pôs a rotina automática fora de escopo com um motivo concreto: "não existe hoje nenhum agendador no sistema" (docs/specifications/0048-gestao-de-cobrancas/specification.md:182-185). O motivo caducou. O worker arq nasceu com a spec 0050, e a 0052 acrescentou a ele um job diário agendado por cron para as 6h da manhã (apps/api/app/worker.py:59,99-102). O obstáculo que fez a 0048 desistir não existe mais — é por isso que o atraso automático volta a caber aqui, e não por mudança de opinião.

4. Resultado esperado

  • Toda cobrança diz de que tipo é — setup, fiança, ou a entrada que junta os dois, e nesta os dois valores aparecem separados —, por onde o dinheiro entra, e que parcela é (n de N). Ninguém digita isso parcela a parcela: tipo e parcela são consequência do que a emissão já decidiu, e o acordo sobre quem recolhe passa a ser registrado uma vez por imobiliária parceira — hoje ele não está registrado em lugar nenhum, e por isso vive na cabeça de quem opera.
  • A competência é somável por essas duas dimensões — cobrado, pago, o que falta receber, multa e quantidade, por tipo e por origem do dinheiro. É o mesmo par de blocos da planilha, agora saindo do produto.
  • E existe uma resposta só. A soma que a Central já mostra e a soma da competência param de divergir: qual recorte de mês vale — vencimento ou competência — passa a ser uma definição escrita, não um acidente de cada implementação.
  • Multa e juros são do sistema, calculados na data do pagamento a partir de parâmetros da garantidora, respeitando uma carência — e o quanto já corre fica visível em valor enquanto a parcela está em atraso, não como a palavra "multa".
  • O atraso não depende de clique, onde o pagamento é observável. A parcela cobrada pela própria garantidora, vencida e não paga, passa a OVERDUE sozinha — e a contagem, o filtro e os alertas que leem o status gravado passam a dizer a verdade. A parcela recolhida pela imobiliária fica de fora disso, e de propósito: enquanto não houver como registrar que ela recebeu (seção 5), marcá-la em atraso confundiria o inquilino que pagou em dia com o que não pagou, e inflaria a inadimplência da home com um número falso. O caminho até o que venceu já existe; o que faltava era o dado estar certo.
  • A competência sai do produto como arquivo, cobrindo os dois blocos que a planilha soma ao pé — "Previsão de faturamento" e "Pagamentos efetuados". O terceiro bloco da aba, o "Acerto Solar", não entra: o total a repassar passa a ser legível como soma derivada do canal, mas o controle que aquele bloco faz — o que cada parcela deve, o que a imobiliária já informou, o registro do repasse recebido — é da spec seguinte, e até lá continua na planilha. Saber o total não é conciliá-lo. (O formato do arquivo é detalhe da Parte II.)

Como se sabe que resolveu. Três perguntas passam a ter resposta sem abrir garantia nenhuma e sem a planilha: quanto de setup e quanto de fiança nesta competência?, quanto por boleto da garantidora e quanto está com a imobiliária?, o que venceu, no que a garantidora mesma cobra, e quanto de encargo já corre? Mais três sinais verificáveis: o total do mês na home e o total da competência na tela de Cobranças passam a ser o mesmo número, ou a diferença entre eles tem nome; a parte de previsão e realizado do relatório mensal passa a ser o arquivo que o produto gera, e não uma aba copiada; e nenhuma parcela de boleto da garantidora fica "pendente" no banco depois de vencida por falta de alguém ter clicado.

5. Fora de escopo

  • O acerto com a imobiliária — extrato por imobiliária, fechar a competência, registrar o repasse recebido, e o estado intermediário "recebido pela imobiliária, ainda não repassado". Foi cogitado: está desenhado no material que originou esta spec e é o item E2 do ROADMAP.md:237-242. Fica para a spec seguinte, por depender de o canal existir como dado — que é justamente o que esta entrega. Aqui o canal "PIX Imobiliária" classifica e, por consequência, permite ler o total a repassar como uma soma; o que não entra é o controle daquele total — extrato por imobiliária, o que a parceira já informou, fechar a competência e registrar o repasse. A máquina de estados da cobrança não ganha estado novo, para não nascer uma parcela presa num estado sem caminho de saída.
  • O atraso automático da parcela que a imobiliária recolhe, pela mesma razão e como consequência direta dela. Sem "informar recebimento" — que é do acerto —, o produto não distingue quem pagou em dia à Solar de quem não pagou (seção 3, passo 5); marcar todas em atraso encheria a inadimplência da home de falso positivo justamente enquanto esta spec tenta fazer aqueles números passarem a valer. Essas parcelas continuam podendo ser marcadas à mão, como hoje. A rotina automática nasce escopada ao canal da garantidora, e passa a alcançar as demais quando o acerto lhe der como saber.
  • Acesso da imobiliária à tela agregada. O material de origem propõe que o AGENCY_USER marque ele mesmo o recebimento. Isso reverteria a decisão da spec 0038 (RN19a), reafirmada na 0048, de não expor o agregado ao canal — o que revelaria a estrutura interna de quem a contrata. Decisão mantida: quem opera continua sendo a garantidora, a partir do que a imobiliária informar por fora. A pergunta volta na spec do acerto, que é onde ela pesa.
  • Emissão de boleto e de PIX, e conciliação bancária. Billing continua sendo registro contábil, não boleto emitido (ROADMAP.md:139-142). Fica fora também o campo de linha digitável / chave copia-e-cola e o botão de copiá-la: sem emissão, alguém teria de digitar esse código à mão parcela a parcela, que é o tipo de trabalho manual que esta spec existe para tirar.
  • Cobrar o inquilino atrasado a partir do produto — lembrete por e-mail ou WhatsApp, manual ou em régua (D−3, D+1, D+7). Foi cogitado e sai inteiro, por não ter dor atrás: quem fala com o inquilino é a imobiliária, que cobra a garantia junto do aluguel (CLAUDE.md, Domínio). A garantidora não tem hoje esse relacionamento, e construir o envio aqui seria criar um caminho de comunicação que ninguém pediu e que atravessaria o canal por cima. O que esta spec entrega no lugar é o dado do atraso correto — de quem a imobiliária precisa para cobrar.
  • Marcar a garantia como inadimplente a partir de N parcelas em atraso, e alimentar um módulo de inadimplência. O módulo não existe; criar o gatilho antes do destino é escrever um estado que ninguém lê.
  • Nota fiscal do repasse — parte do mesmo E2 do roadmap, junto do fechamento.
  • Renegociação e parcelamento fora do calendário mensal padrão — mantido fora pelo mesmo motivo da 0048 (docs/specifications/0048-gestao-de-cobrancas/specification.md:191-193): falta um caso concreto que diga que forma isso tem.
  • Importar o histórico da planilha (as competências já fechadas da Solar) para dentro do produto. Esta spec cobre da competência corrente em diante; trazer o passado é um trabalho de migração de dados de cliente, com conferência, e não cabe junto da mudança de modelo.

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 Nota Veredicto
1 1 3 3 7 Reprovada
2 0 3 2 7 Reprovada
3 1 3 2 7 Reprovada
4 0 3 4 8 Aprovada

Resultado: aprovada na rodada 4.

Triagem dos achados da rodada 4. Os três Important e os quatro Minor foram corrigidos antes de fechar a parte — nenhum ficou em aberto por escolha: a contradição entre o indicador "a repassar" e o bloco Acerto Solar (agora a spec diz que ler o total é uma coisa e conciliá-lo é outra, da spec seguinte), a carência sem lastro (o usuário confirmou que o contrato do piloto a concede; a dor está escrita), a citação do CLAUDE.md que afirmava mais do que o arquivo diz, a colisão de "origem" com BillingOrigin, o terceiro ponto de quebra, a atribuição do worker à spec errada e a lista de causas que fechava cedo demais.

Pendências aceitas:

  • A seção Domínio do CLAUDE.md fica devendo a generalização do canal. Ela fixa, sem exceção, que do 2º mês em diante quem recolhe é a imobiliária; esta spec passa a admitir parceira que não recolhe. A Parte I registra a divergência em vez de escondê-la, e a atualização do CLAUDE.md entra no impacto da Parte III — não aqui, porque mexer no documento de domínio antes de a regra existir no código inverte a ordem.
  • O número de dias da carência não está definido. O contrato do piloto a concede, o que basta para a dor; quanto vale o padrão por garantidora é decisão da Parte III e vai para Decisões em aberto — inventar um número aqui seria exatamente o que a régua proíbe.

Parte II — A experiência

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

7. Jornada pretendida

A feature tem quatro superfícies. A primeira é onde o trabalho do mês acontece; a segunda é a mesma cobrança vista de dentro da garantia; as duas últimas são configuração, visitada uma vez e quase nunca mais.

  • Ponto de entrada, o do dia a dia: menu OPERAÇÃO → Cobranças (nav-groups.tsx:195-204), que já existe. O item passa a carregar a contagem de parcelas em atraso — a sider já sabe desenhar badge por item, e é isso que faz o atraso ser visto sem entrar na tela. A Central de Operações continua linkando para cá de duas formas, e as duas continuam chegando ao lugar certo — que agora tem aba. O link dos alertas, que pede o que está vencido sem dizer de que mês, passa a abrir a aba Atrasados, e não a competência corrente filtrada por atraso: são recortes diferentes, e o segundo esconderia justamente a parcela velha que o alerta está denunciando. O link da fila de trabalho, que aponta uma cobrança específica, segue levando à garantia dela.
  • A segunda superfície é a mesma cobrança vista de dentro da garantia — a lista embutida no detalhe (OPERAÇÃO → Garantias, uma garantia; é um painel abaixo do detalhe no desktop e uma seção recolhível no mobile — não é aba, e numa spec que estreia abas a palavra não pode ser usada solta). Ela não é uma tela à parte a desenhar: é a mesma cobrança, e por isso herda o vocabulário novo — tipo, parcela e canal, o selo Aguardando a imobiliária, o indicador de comprovante na parcela paga, a mesma regra de "Marcar inadimplente", e o mesmo painel de pagamento, que cresce lá pelo mesmo motivo. Dizer isto é necessário porque o painel é compartilhado pelas duas telas: crescê-lo numa cresce na outra, com ou sem spec.

Mas as colunas são outras, e não se herdam. Ali a lista é de uma garantia só, então Garantia, Inquilino e Imobiliária não existem como coluna — e é justamente a fusão delas que abre as três vagas na tela agregada. No desktop a lista embutida tem hoje Competência, Vencimento, Valor, Valor pago, Status e Ações; ela não vira a tabela de nove colunas: ganha Parcela, Tipo e Canal antes de Vencimento, e aplica a mesma fusão Competência→Vencimento, ficando em oito. No mobile o cartão de lá tem outra anatomia — o código é a competência e o título é o vencimento —, e recebe tipo, parcela e canal como a linha de contexto que hoje ele tem livre, mais o comprovante no rodapé.

O que ela não ganha é indicador, resumo, aba ou exportação: são as cobranças de uma garantia só, e somar as parcelas de um contrato por tipo e canal não responde pergunta nenhuma. - Pontos de entrada da configuração: ADMINISTRAÇÃO → Parâmetros, para os juros, a carência da multa e o meio pelo qual a garantidora cobra; e CADASTROS → Imobiliárias, no cadastro de cada parceira, para dizer se ela recolhe as mensalidades. Nenhuma das duas é visitada no fechamento do mês.

Jornada, caso feliz — o fechamento da competência. É o mesmo mês descrito na seção 3, sem os passos que hoje acontecem fora:

  1. O operador abre Cobranças. A tela já está na competência corrente.
  2. Ele lê, antes da lista, quanto o mês prevê, quanto entrou, quanto falta entrar por cada lado e quanto está em atraso — os números que hoje ele soma na planilha.
  3. Abaixo, o resumo por tipo e canal: uma linha para cada combinação (setup no meio da garantidora, fiança no meio da garantidora, fiança com a imobiliária) com cobrado, pago, o que falta receber e quantas parcelas. É o par "Previsão de faturamento" / "Pagamentos efetuados" da planilha, agora derivado das cobranças.
  4. Na lista, cada parcela já diz de que tipo é, que parcela é (5/12) e por onde o dinheiro entra. Ele não reclassifica nada.
  5. Quando um boleto é pago, ele registra o pagamento pela própria lista. O painel abre com a conta montada — principal, multa e juros, contada a carência, na data que ele informar — e com a distribuição entre setup, fiança e encargo já preenchida. Ele confere, ajusta se o combinado foi outro, e confirma.
  6. O que venceu e não foi pago já está marcado — nenhum clique diário. Ele abre a aba Atrasados quando quer olhar só isso, de todas as competências, do mais atrasado para o menos.
  7. No fim, exporta a competência. O arquivo é o que vai para a imobiliária e para a contabilidade, no lugar da aba copiada.

O que deixa de existir na jornada: os passos 3 e 4 da seção 3 (reclassificar linha a linha e relançar a baixa na planilha), a calculadora do passo 6, e o clique de marcar inadimplente. O passo 5 — a parcela que a Solar recolheu — continua igual, porque registrar que ela recebeu é da spec seguinte; a diferença é que agora essa parcela está somada e rotulada em vez de indistinta.

A competência passa a ter uma definição só. Hoje a home soma o mês por janela de vencimento e a tela de Cobranças filtra por competência (seção 1). A partir daqui vale a competência — é a palavra que o negócio usa e a que o relatório pede —, e a home passa a usá-la também; a cobrança avulsa sem competência conta no mês do próprio vencimento, para não sumir de nenhum total. O efeito visível é que o número da home e o da tela passam a ser o mesmo, e é por isso que esta spec toca a Central de Operações sem ter a Central como assunto.

Custo de uso. Na leitura do mês, nada é digitado: tipo, parcela, canal, previsto, recebido, multa e juros são derivados do que o sistema já sabe — a precificação congelada na emissão, o dia de vencimento da garantia, a parceria da imobiliária e os parâmetros da garantidora.

O que se digita está todo no painel de pagamento, e mesmo ali quase tudo chega preenchido: a data vem com hoje, o valor vem com o total devido naquela data, e a distribuição vem calculada. O operador só toca no que divergiu do combinado. Os três campos que nascem vazios são opcionais por natureza — o motivo do abono (exigido só se ele abonar), a observação e o comprovante.

A configuração custa três campos por garantidora, uma vez (juros, carência da multa e o meio pelo qual ela mesma cobra), e um campo por imobiliária parceira, uma vez por parceira (se ela recolhe as mensalidades). Esse último é a informação que hoje não está em lugar nenhum e vive na cabeça de quem opera.

7.A O padrão de abas — o que nasce aqui

A tela passa a ter duas abas: Cobranças do mês e Atrasados. O app não tem abas dentro de tela em lugar nenhum hoje, então este é um padrão novo e compartilhado, não um arranjo local — a spec seguinte acrescenta a terceira (Acerto com imobiliárias) sem redesenhar nada.

  • O que uma aba é aqui: um recorte da mesma lista, com pergunta própria. Cobranças do mês responde "como está esta competência"; Atrasados responde "o que está vencido, de qualquer competência". Não são telas diferentes disfarçadas — mudam o recorte, não o assunto.

E "vencido" aqui é um fato, não um status gravado: passou do vencimento e não foi pago. É o critério que a tela já usa hoje, e é o único que funciona quando a rotina automática cobre um canal e não o outro — a parcela que a imobiliária recolheu e ninguém marcou venceu do mesmo jeito, e uma fila de trabalho que a esconde não serve para trabalhar. O que a aba não faz é afirmar inadimplência de quem talvez tenha pago: ver a seção 7.1, sobre o rótulo próprio dessas parcelas. - Cada aba carrega um rótulo e, opcionalmente, uma contagem. Atrasados traz a sua; é a mesma contagem do badge do menu, e as duas saem da mesma fonte, para não divergirem.

Quando esse número é calculado importa tanto quanto de onde ele vem. As contagens do menu hoje são preenchidas por uma chamada só, na abertura do Painel, e ficam em memória pelo resto da sessão — o que basta para um número que ninguém muda de dentro do app. Este não é assim: quem registra um pagamento dentro de Cobranças acabou de mudá-lo. Então a contagem se refaz em dois momentos além da abertura do Painel — ao entrar na tela de Cobranças, e depois de cada ação que muda o estado de uma cobrança (registrar pagamento, marcar inadimplente, remover, restaurar). Sem isso, a pessoa dá baixa na última parcela vencida e o menu continua dizendo que há uma — e um número que mente é pior que número nenhum, porque ela para de olhar para ele. - A aba ativa vive na URL — e só ela. Sem isso, o link da Central de Operações não tem como abrir direto no recorte certo, e recarregar perderia em que recorte a pessoa estava. Hoje o único lugar do app que escreve estado na URL é a Ajuda, e é dela que o molde vem. Os filtros continuam fora da URL, como em todas as listas do app: recarregar volta para a competência corrente, sem filtro. E a leitura de ?status= que a tela faz hoje na montagem sai junto — ela existia só para o alerta da Central, que agora chega pela aba; mantê-la significaria dois caminhos para o mesmo destino, um deles caindo na competência corrente, que é o recorte errado (7). Levar os filtros junto seria mudar o comportamento de toda lista do produto a partir desta tela, e essa é decisão de outra spec — o que esta precisa é só que o link do alerta caia na aba certa. - Por que não um filtro. "Em atraso" é um valor do filtro de Situação, e poderia parecer bastar — mas não basta. O vencido se reparte em dois valores de Situação (Em atraso e Aguardando a imobiliária, ver 7.1), e o seletor é de valor único: escolher um esconde o outro, e é justamente o conjunto dos dois que forma a fila. Além disso o recorte abandona a competência (atraso não tem mês) e troca a ordenação (do mais vencido para o menos). Um filtro que precisasse de dois valores ao mesmo tempo e ainda mudasse silenciosamente outros dois filtros é pior de entender que uma aba. - Trocar de aba preserva o que as duas têm em comum e descarta o resto. Busca, Tipo, Canal e Mostrar removidos atravessam; Competência e Status não existem em Atrasados e voltam como estavam ao retornar. A pessoa filtrando por uma imobiliária não perde o filtro ao ir ver o que dela está vencido — é justamente a pergunta seguinte. - A fita de abas não pode ser confundida com a de filtros, e em 375px as duas ficam uma sobre a outra. Elas se distinguem por posição e por forma: a de abas fica acima da barra de filtros, encostada no cabeçalho, e é sublinhada — a ativa marcada por baixo, no padrão de aba; a de filtros usa pílulas arredondadas e preenchidas quando ativas, que é o que ela já faz hoje. Duas fileiras roláveis empilhadas é o custo de estrear abas no mobile, e é aceito com essa distinção visual, não com uma terceira. - O indicador e a aba não podem ter o mesmo nome. O cartão da competência chama-se Em atraso no mês e a aba chama-se Atrasados — o primeiro conta só o que venceu dentro da competência aberta, a segunda varre todas. Sem essa diferença no rótulo, dois números diferentes com o mesmo nome convivem na mesma tela.

7.B O canal — três valores, e ninguém escolhe entre três

O canal é o vocabulário central desta spec: é coluna, é filtro, é uma das duas dimensões do resumo e é campo do painel de pagamento. Ele precisa ser uma lista fechada, e é esta:

Valor Quem recolhe Quando a parcela nasce com ele
Boleto da garantidora a garantidora é o meio dela, e a parceira não recolhe
PIX da garantidora a garantidora idem, quando o meio dela é PIX
PIX da imobiliária a imobiliária só nas mensalidades, e só quando a parceira recolhe

Ninguém escolhe entre os três ao configurar. O valor sai da combinação de duas escolhas binárias, cada uma feita uma vez e em lugar diferente:

Onde se escolhe Rótulo do campo Valores O que governa
Parâmetros, painel Cobrança e sinistro Meio de cobrança da garantidora Boleto · PIX como a garantidora cobra quando é ela quem cobra
Cadastro da imobiliária, painel Dados gerais A imobiliária recolhe as mensalidades Sim · Não (padrão Não) quem recolhe da 2ª parcela em diante, naquela parceira

E a combinação produz o canal de cada parcela:

Parcela A parceira recolhe? Canal gravado
Entrada (setup e 1ª mensalidade) — não é consultada o meio da garantidora
Mensal Não o meio da garantidora
Mensal Sim PIX da imobiliária
Avulsa, lançada à mão — não é consultada o meio da garantidora

Não há terceiro estado de "herdando". O campo da parceira é sim ou não, com padrão Não, e é isso que ele mostra — um seletor com uma terceira opção que significasse "o que a garantidora disser" faria a pessoa ter de abrir Parâmetros para saber o que escolheu.

É por construção que a entrada nunca cai na imobiliária: ela simplesmente não consulta o campo da parceira. Não existe escolha que a leve até lá, em vez de existir uma regra que a recuse depois — e combinação inválida que não pode ser expressa não precisa de mensagem de erro.

O canal é gravado na parcela quando ela nasce, não calculado na leitura. Mudar a parceria em março não pode reescrever janeiro: o que foi cobrado por um caminho continua tendo sido cobrado por ele, e o resumo de uma competência fechada não muda depois.

O único lugar que escolhe entre os três é o painel de pagamento, e ele não configura nada: ele registra um fato passado. Boleto emitido e pago por PIX acontece, e o painel oferece os mesmos três valores para isso, sem alterar o canal da cobrança. O resumo continua somando pelo canal previsto — é o que "previsão" significa, e é o que permite comparar previsto com realizado; a divergência entre previsto e efetivo fica legível na própria parcela, que é onde ela interessa.

7.1 Desktop

A tela de Cobranças, de cima para baixo. O PageHeader continua com título, subtítulo e ícone; ele passa a ter uma ação, Exportar competência, como ação secundária — o topo está livre hoje, e três é o teto do componente, então sobra folga para a spec do acerto. Abaixo dele, a fita de abas; abaixo dela, o que muda de aba para aba.

Na aba Cobranças do mês, nesta ordem:

  1. Cinco indicadores, lado a lado, no mesmo formato de cartão que a Central de Operações e as Consultas de Crédito já usam: Previsto no mês, Recebido, A receber pela garantidora, A repassar pela imobiliária, Em atraso no mês. Os cinco recortam a mesma base. Quatro deles formam uma identidade, e o quinto é um recorte declarado:

previsto = recebido + abatido + a receber + a repassar

Os quatro falam de principal, nunca de encargo. Multa e juros não estão previstos — eles nascem do atraso —, então somá-los a Recebido faria uma competência de inadimplentes fechar com recebido maior que previsto, que é absurdo de ler. O encargo tem medida própria: quanto já corre no cartão de atraso, e quanto foi efetivamente recebido na coluna do resumo.

Por isso o abono de multa não produz abatido. Abatido é desconto no principal — o valor que a garantidora aceitou não receber e não vai mais cobrar. Abonar a multa apenas faz um encargo não ser recebido, e isso se lê na coluna de encargo recebido, que fica menor que o encargo corrido. São duas renúncias diferentes e não podem cair no mesmo número. Abatido não é um sexto cartão: aparece como detalhe do cartão Recebido e como coluna do resumo, porque é número de fechamento, não de acompanhamento.

Em atraso no mês fica fora da identidade, e é por isso que é um recorte: ele atravessa a receber e a repassar, contando o que já venceu dentro dos dois. Traz no detalhe quantas parcelas e quanto de encargo já corre, e conta pelo fato (venceu e não foi pago), como a aba Atrasados — não pelo status gravado. 2. O resumo por tipo e canal, num painel próprio. É a tabela que a garantidora entrega hoje, e é a razão de a spec existir — fica acima da lista, porque é o que se olha primeiro no fechamento. As linhas são fixas, não derivadas do que apareceu no mês:

  A **ordem** das linhas é fixa; **quais** linhas existem, não. Há uma linha por

combinação de tipo e canal presente na competência — combinação sem nenhuma parcela não aparece, para a tabela não encher de zeros —, sempre nesta ordem: Setup antes de Fiança, e dentro de cada tipo os canais da garantidora antes do da imobiliária. Avulsas é sempre a última antes do total.

As linhas imprimem o canal concreto, não "o meio da garantidora": a garantidora que trocar de boleto para PIX no meio do ano terá as duas coisas na mesma competência, e colapsá-las apagaria justamente a distinção que a coluna e o filtro sustentam.

Colunas: cobrado, pago, abatido, a receber, encargo recebido e parcelas.

A entrada é decomposta, não vira linha própria — é exatamente para isso que a cobrança composta guarda os dois valores discriminados: a parte de setup soma na primeira linha e a parte de mensalidade na segunda. Sem isso, "quanto de setup entrou neste mês" voltaria a exigir somar duas linhas, que é a dor da Parte I.

Na contagem de parcelas, a entrada conta uma vez, na linha de Setup — ela é, antes de tudo, a cobrança do setup, e é lá que se procura por ela. A linha de fiança conta só as mensais; o total conta parcelas distintas, e por isso é a soma das linhas existentes, sem correção nenhuma. Uma linha fica com o valor de uma parcela que ela não contou, e isso é preferível a contar a mesma parcela duas vezes num arquivo que vai para a contabilidade.

O filtro de Tipo tem três valores — Setup, Fiança e Avulsa — e trabalha sobre o que a parcela contém. Filtrar por Setup alcança a entrada composta, e filtrar por Fiança também: ela contém os dois. Setup + Fiança não é opção de filtro, porque é conteúdo e não tipo — tratá-lo como um quarto valor faria "mostre-me o setup do mês" esconder justamente as parcelas onde há mais setup. Avulsa é disjunta das outras duas, como a linha dela no resumo. 3. A barra de filtros, com busca (inquilino ou número da garantia) e os seletores de Competência, Tipo, Canal, Situação e Mostrar removidos. Competência e Situação já existem (a segunda chamava-se Status, e muda de nome junto da coluna); Tipo e Canal são novos. Os filtros valem também para os indicadores e para o resumo — filtrar tem de mudar os números, senão o total contradiz a lista logo abaixo dele.

Com uma exceção, e ela é deliberada: Mostrar removidos não entra nos totais. Ele revela na lista o que foi removido, para conferir ou restaurar; somá-lo ao previsto do mês faria o total crescer ao se ligar um filtro de auditoria, e o número que a garantidora entrega passaria a incluir cobrança que ela mesma apagou. Indicadores e resumo ignoram esse filtro e dizem sempre respeito ao que está vigente — é a única vez em que a lista mostra mais do que o total conta, e a linha de removidos fica marcada como já fica hoje.

Tipo e Canal viram chip aplicado na segunda linha da barra, como Competência e Situação já viram, e entram no Limpar filtros. Trocar de aba não limpa nada — os filtros comuns atravessam (7.A), e o que não existe na outra aba apenas deixa de ter chip enquanto se está nela.

A Competência não pode ficar vazia, e por isso perde a opção "Todos". Ela é a única entre os filtros que não é um recorte opcional: é o assunto da aba — os cartões dizem "no mês", o resumo é da competência, o arquivo leva o nome dela. Sem mês escolhido nada disso tem referência. Então o seletor nasce na competência corrente e só troca de mês; Limpar filtros devolve a competência corrente em vez de esvaziá-la, e limpa o resto. Ver várias competências de uma vez não é o que esta tela faz — o que atravessa meses é a aba Atrasados, e é por isso que ela existe.

Mostrar removidos continua fora do *Limpar filtros, como já está hoje. Ele é controle de auditoria, não recorte de trabalho: quem o ligou para conferir uma remoção não espera perdê-lo ao limpar a busca. É a mesma razão pela qual ele já está fora dos totais. 4. A lista. Ela continua em nove colunas*, embora ganhe quatro informações: três colunas de hoje se fundem em duas células de duas linhas, e as vagas abertas recebem Parcela, Tipo e Canal.

# Hoje Passa a ser O que mostra
1 Garantia Inquilino nome em cima; abaixo, o número da garantia e a imobiliária, atenuados
2 Inquilino Parcela 5/12 — a posição entre as mensalidades. A cobrança que não carrega mensalidade nenhuma (a taxa de adesão isolada, e a avulsa) mostra Adesão e Avulsa
3 Imobiliária Tipo Setup, Fiança, ou Setup + Fiança quando a entrada junta os dois
4 Competência Canal selo curto com um dos três valores da 7.B
5 Vencimento Vencimento a data; abaixo, a competência, atenuada
6 Valor Valor o principal; abaixo, em tinta de alerta e com valor, o encargo que já corre
7 Valor pago Pago o que entrou, ou travessão
8 Status Status o selo de hoje
9 Ações Ações as de hoje — registrar pagamento, marcar inadimplente, remover, restaurar — mais o comprovante, como ícone entre elas quando existir

Três colunas de hoje deixam de existir como coluna. Garantia e Imobiliária descem para a segunda linha da célula do Inquilino — o número da garantia continua monoespaçado e atenuado, só que abaixo do nome em vez de ao lado dele; é a mesma composição que o cartão do mobile já usa, e alinhar os dois viewports é ganho, não perda. Competência vira a segunda linha do Vencimento: são duas datas da mesma parcela, e separadas custavam uma coluna que a tabela não tem. O que decide quem se funde é o fechamento: imobiliária e competência quase sempre estão fixadas no filtro, e repeti-las em toda linha gasta largura com o que não varia na tela.

A partir de uma linha se faz o que já se faz hoje: clicar na linha abre a garantia, e a célula de ações registra o pagamento sem sair da tela. "Marcar inadimplente" continua, e não é resíduo — mas passa a aparecer só onde ainda tem trabalho: na parcela de canal da imobiliária, vencida e ainda não marcada. A rotina automática cobre o que a garantidora mesma cobra (seção 5), então ali o botão nunca mais faz nada; e na parcela já marcada, ele repetiria o que está feito. Mantê-lo visível em toda linha pendente, como hoje, ofereceria uma ação que na maioria dos casos ou é inócua ou antecipa em horas o que a rotina faria sozinha.

A parcela que a imobiliária recolheu, vencida e não marcada, tem rótulo próprio. Ela aparece na aba Atrasados e no cartão Em atraso no mês, porque venceu — mas o selo de status não diz "Em atraso": diz Aguardando a imobiliária. A distinção não é cosmética. No canal da garantidora, "não pagou" é observação: o dinheiro não entrou na conta dela. No canal da parceira, o produto não sabe se o inquilino pagou — quem sabe é a Solar, e é ela quem vai dizer, na spec do acerto. Afirmar inadimplência de quem talvez esteja em dia é o erro mais caro dos dois, e é o mesmo motivo pelo qual a rotina automática não a alcança.

O selo é o quarto valor da coluna 8, e o filtro acompanha. A coluna deixa de mostrar "o status gravado" e passa a mostrar a situação — Pendente, Em atraso, Aguardando a imobiliária, Pago, Cancelado —, e o filtro que hoje se chama Status passa a se chamar Situação, com os mesmos valores da coluna. Um filtro que oferecesse menos valores do que a coluna exibe deixaria parcelas visíveis e inalcançáveis. Quando alguém usa o "Marcar inadimplente" naquela parcela, ela deixa de estar Aguardando a imobiliária e passa a Em atraso, como qualquer outra — é exatamente o que a ação significa: alguém afirmou o que o produto não sabia. No cartão do mobile o selo do topo à direita é o mesmo, pelos mesmos cinco valores.

Por consequência, o indicador de inadimplência da home continua lendo o status gravado, e não o fato. Ele é uma afirmação sobre a carteira — quanto dela está inadimplente —, não uma fila de trabalho, e é justo que só conte o que alguém afirmou. A tela de Cobranças conta pelo fato porque ali a pergunta é outra: o que precisa de atenção. A diferença entre os dois números passa a ter nome — é exatamente o conjunto de parcelas Aguardando a imobiliária —, que é o que a seção 4 prometeu no lugar de fingir que ela não existe.

A cobrança avulsa, lançada fora do calendário, não tem parcela nem canal derivado — ela nasce de um lançamento manual, não da geração. Na lista, Parcela mostra travessão e Tipo mostra Avulsa; o Canal é o meio da garantidora, porque é ela que está cobrando. No resumo, essas cobranças formam uma linha própria, Avulsas, em vez de serem rateadas entre setup e fiança — elas não são nenhum dos dois, e diluí-las falsearia justamente a separação que a spec existe para dar. A opção Sem competência do filtro deixa de existir, e é consequência direta de a competência ter passado a ter uma definição só (seção 7): a avulsa que nasceu sem mês passa a pertencer ao mês do próprio vencimento, aparecendo na lista e nos totais dessa competência. Mantê-la fora seria somá-la a um total cuja lista não a mostra — a mesma contradição entre total e lista que esta tela existe para acabar.

A aba Atrasados: a mesma lista, sem indicadores e sem resumo — ela não é uma competência, e somar meses diferentes não significa nada. Ficam a busca, Tipo, Canal e Mostrar removidos. Saem dois: Competência, porque atraso não tem mês; e Situação, porque dentro da fila ela só teria dois valores (Em atraso e Aguardando a imobiliária) e o seletor é de valor único — escolher um esconderia metade da fila, que é o oposto do que se abre a aba para fazer. A coluna continua mostrando os dois, que é onde a distinção serve. A ordenação da aba do mês é a de hoje — vencimento crescente, e nada mais: dentro de uma competência isso já põe o vencido no topo, e ordenar por situação contradiria a tela inteira, que conta pelo fato e não pelo estado gravado. É dessa ordem que esta aba troca: aqui a ordem é por dias de atraso, do maior para o menor, e a coluna de Vencimento mostra há quantos dias venceu. O que sobra é uma fila de trabalho, que é para isso que se abre essa aba.

A exportação existe nas duas abas, e exporta o que está na tela. Na aba do mês o arquivo é a competência; na de atrasados é a fila inteira, sem recorte de mês — e o rótulo da ação muda junto, Exportar competência contra Exportar atrasados, para ninguém baixar um arquivo diferente do que estava lendo. O arquivo respeita todos os filtros aplicados na aba de onde saiu — busca, tipo e canal nas duas, mais competência e situação na do mês —, pelo mesmo motivo que os totais os respeitam: um arquivo que ignora o filtro contradiz a tela que o gerou. A única exceção é a mesma dos totais — removidos não entram no arquivo.

O que a pessoa recebe é uma planilha, e o arquivo espelha a aba de onde saiu:

  • da competência, com duas abas — o resumo por tipo e canal, e a lista de parcelas com as mesmas colunas da tela. O nome traz a competência, para as competências não se confundirem na pasta de quem recebe;
  • dos atrasados, com uma aba só — a fila. Não há resumo ali por decisão desta mesma seção, e o nome traz a data em que foi gerado, porque é o que identifica uma fila que muda todo dia e não pertence a mês nenhum.

Nos dois casos os valores saem como número com formato de moeda, não como texto — é o que permite somar e conferir no destino, que é o que a contabilidade faz com o arquivo.

O painel de registrar pagamento

É o único ponto da jornada em que alguém digita, e é onde o ganho de multa e juros aparece. Ele cresce do campo único de hoje para quatro blocos.

Continua sendo um diálogo, e não um painel lateral. O app não tem painel lateral em lugar nenhum, e esta spec já estreia dois padrões — abas e exportação de arquivo; um terceiro não se paga. O que muda é a largura: de estreito (um campo) para a largura média, que é o que permite a conta aberta e a distribuição ficarem lado a lado no desktop, sem rolagem. É essa folga que faz o "tudo cabe junto" ser verdade aqui e não no mobile.

Os quatro blocos, nesta ordem:

  1. Quem e o quê — inquilino, garantia, imobiliária, parcela, vencimento e a situação, para a pessoa confirmar que está na linha certa antes de mexer em dinheiro.
  2. A conta aberta — uma linha por componente: setup, fiança, e o encargo separado em multa e juros, com o total devido na data. É a explicação que hoje não existe: o valor sugerido deixa de ser um número pronto e passa a mostrar de onde veio.
  3. Como entrou — a data do recebimento (vem com hoje; não aceita data futura, e aceita data anterior ao vencimento, que é o pagamento adiantado), o canal pelo qual o pagamento efetivamente entrou (pode diferir do canal da cobrança — boleto emitido e pago por PIX acontece) e o valor recebido, que vem com o total devido na data escolhida. Mudar a data recalcula o encargo e, com ele, o total sugerido — é para isso que o campo existe: quem lança dois dias depois registra o encargo do dia do pagamento, não o de hoje.
  4. Para onde vai, e o resto — a distribuição entre setup, fiança e encargo, que chega preenchida e é editável; o abono da multa, que quando marcado exige motivo e zera a parte de encargo da distribuição; a observação; e o comprovante, um arquivo preso àquela parcela.

A distribuição é o que faz o resumo por tipo fechar, e por isso ela tem uma trava: a soma das três partes precisa bater com o valor recebido, e enquanto não bater a confirmação fica bloqueada, com a diferença dita em valor ao lado. Sem a trava, o resumo por tipo passaria a divergir da soma das parcelas — o defeito que esta spec existe para acabar, reintroduzido pela porta do formulário.

Recebido a menos que o devido — o que acontece com a parcela. É o caso do desconto negociado no principal, e ele tem desfecho único: a parcela fica paga, com o valor recebido registrado, e ganha, ao lado do valor pago, uma marca de diferença que mostra quanto foi abatido — o valor, não um ícone nem a palavra, porque é o número que a pessoa vai conferir. Ela não continua em aberto — deixar uma parcela recebida pendindo faria o atraso automático marcá-la depois, e a garantidora cobraria de novo quem já pagou o combinado.

Isso alimenta a identidade dos indicadores: o principal que entrou soma em Recebido, e a diferença de principal vai para abatido — não migra para a receber, porque ninguém mais espera esse dinheiro, e não some, porque então o previsto deixaria de fechar. O resumo traz a mesma coluna, para o mês não parecer ter faturado menos sem explicação. Recebido a mais que o devido não é tratado aqui: é crédito a favor do inquilino, e crédito é conceito que o produto não tem — a confirmação recusa valor acima do total devido, dizendo o teto.

O comprovante é um arquivo por pagamento, dos mesmos tipos que o produto já aceita nos demais anexos, com o mesmo teto de tamanho. Enquanto sobe, a confirmação espera por ele e diz que está enviando; se o envio falha, o pagamento não é registrado pela metade — o painel volta com o erro e tudo o que foi digitado continua lá, e a pessoa pode confirmar sem o anexo se preferir. Depois, ele se lê na própria linha: a parcela paga passa a mostrar um indicador de comprovante, que abre o arquivo. Sem isso o anexo entraria e nunca mais seria visto — a parcela paga perde as ações de escrita, e o painel não reabre.

7.2 Mobile (375px)

O fechamento do mês não se faz no celular — é trabalho de planilha larga, e a Parte I o descreve numa mesa. Mas consultar se faz: o operador em trânsito abre para ver quanto falta entrar, ou o que venceu. É esse uso que 375px precisa servir bem, e é ele que decide o que cai.

A fita de abas rola na horizontal, sem barra de rolagem visível, exatamente como a fita de pílulas de filtro já rola hoje — é o único padrão do app para "uma fileira de controles que não cabe". Cada aba é um alvo de 44px, e a contagem de atrasados vai dentro do rótulo, não num badge solto que apertaria ainda mais a linha.

O badge de atraso no celular mora na gaveta, e a promessa da seção 7 vale menos aqui. A barra inferior tem três destinos fixos mais "Mais", e Cobranças não é um dos três — ela está dentro da gaveta. O badge acompanha o item lá dentro, e o ponto do "Mais" não muda: ele já significa uma coisa — há novidade de versão por ler — e empilhar um segundo sentido nele destruiria o único que ele tem, que é o defeito que a 7.A evita com cuidado no par Em atraso no mês / Atrasados. A consequência, dita: no celular o atraso se vê ao abrir a gaveta ou a própria tela, não de relance da barra inferior. É perda real, e é preferível a um sinal ambíguo; quando Cobranças subir para a barra, o badge sobe com ela.

Os indicadores caem de cinco para três: Previsto no mês, Recebido e Em atraso no mês — o mesmo rótulo do desktop, porque é aqui, com a fita de abas logo acima dos cartões, que confundi-lo com a aba Atrasados custaria mais caro. Os cartões já se organizam dois por linha em 375px, então cinco ocupariam três fileiras e empurrariam a lista inteira para baixo da dobra — quem abre no celular quer o número e a lista. Os dois que saem, A receber pela garantidora e A repassar pela imobiliária, são justamente a quebra por canal, e é dentro do resumo que ela mora.

O resumo por tipo e canal nasce recolhido, atrás de um controle que diz quantas linhas tem. Aberto, cada combinação vira um bloco empilhado — tipo e canal no topo, a quantidade de parcelas ao lado deles, e cobrado / pago / a receber em duas colunas dentro do bloco. O total é o último bloco, destacado. Tabela de seis colunas não cabe em 375px, e o app não rola tabela de dado na horizontal em lugar nenhum: o que não cabe vira cartão. Recolhido por padrão porque, aberto, ele sozinho preenche a tela.

Duas das seis colunas do desktop ficam de fora do bloco: abatido e encargo recebido. As duas são números de fechamento — quanto se renunciou de principal e quanto de multa entrou, decompostos por combinação —, e fechamento é no desktop. O encargo tem, ainda, o cartão Em atraso no mês logo acima, que é o total dele. Levá-las para o bloco custaria seis medidas empilhadas em 375px para responder perguntas que ninguém faz no celular.

Os filtros ficam na fita de pílulas: Competência, Tipo, Canal, Situação e Mostrar removidos, mais a busca acima. A Competência volta ao mobile — hoje ela não existe lá, por decisão da spec 0048, e essa decisão não sobrevive a esta: com o resumo por competência na tela, um mobile que não troca de mês mostra um total que a pessoa não consegue mover. A fita rola; são quatro pílulas, cinco para quem pode restaurar — e ela já comporta isso.

A lista vira cartão, no mesmo formato das demais listas do app, com o teto de três linhas de contexto que o padrão impõe:

Posição no cartão Conteúdo
topo à esquerda número da garantia, monoespaçado e atenuado
topo à direita o selo de status, mais o de removido quando for o caso
título o inquilino
contexto, linha 1 a imobiliária
contexto, linha 2 tipo, parcela e canal na mesma linha — Fiança · 5/12 · PIX Imobiliária; sem parcela, só tipo e canal
contexto, linha 3 vencimento e competência — Vence 20/07 · Jul/2026
rodapé à esquerda o valor; em atraso, o encargo aparece junto, em tinta de alerta; pago a menos, a marca de diferença vem colada ao valor
rodapé à direita as ações, em botões de 44px, e o comprovante como um ícone entre elas quando existir

A marca de diferença e o comprovante ficam no rodapé, não nas linhas de contexto — as três já estão ocupadas, e o teto é rígido. Cabem ali porque nenhum dos dois é contexto: a diferença qualifica o valor que está do lado, e o comprovante é uma ação (abrir o arquivo), não um dado. É também o que faz o anexo continuar alcançável no celular — sem esse ícone ele entraria e nunca mais seria visto, já que a parcela paga perde as ações de escrita e o painel não reabre.

As três informações novas couberam numa linha só de contexto porque são curtas e se leem juntas; foi isso que permitiu não sacrificar nem a imobiliária nem as duas datas. O que não aparece no cartão é o valor pago em campo próprio — quando a parcela está paga, o rodapé mostra o pago no lugar do previsto, com o rótulo trocado, que é como as demais listas já fazem.

A paginação é a barra fixa de rodapé dos dois viewports, como hoje. Não há "carregar mais".

A exportação fica na mesma ação do topo, que em 375px vira um botão de ícone de 44px — o componente de cabeçalho já decide essa forma sozinho, e o rótulo vira nome acessível. Como não há rótulo visível para mudar, o estado "gerando" aqui é o ícone trocado por um indicador de progresso no mesmo alvo de 44px, com o botão desabilitado; o rótulo acessível acompanha. O arquivo desce para os downloads do navegador; na versão instalada o destino é o do sistema, e a tela não controla isso — por isso a ação avisa, ao terminar, que o arquivo foi gerado e com que nome, em vez de assumir que a pessoa viu acontecer.

Aba Atrasados no mobile: sem indicadores e sem resumo, como no desktop, e com a mesma fita do desktop — busca, Tipo, Canal e Mostrar removidos, sem Competência e sem Status. A lista de cartões vem ordenada pelo mais vencido, e é a tela mais útil das duas no celular.

O painel de pagamento em 375px é o ponto mais delicado desta spec, porque é um formulário longo com teclado virtual em cima. Ele ocupa a tela inteira em vez de flutuar, e os quatro blocos empilham na mesma ordem do desktop. Três decisões o tornam usável:

  • A conta aberta e o total devido ficam presos no topo, visíveis enquanto a pessoa rola — o número que ela está conferindo não pode sair da tela quando o teclado sobe. É o inverso do desktop, onde tudo cabe junto.
  • A distribuição nasce recolhida. São três campos numéricos que quase nunca se toca, e abertos empurram a confirmação para fora de alcance. Recolhida, ela mostra numa linha como ficou; se a soma não fecha, abre sozinha e a diferença aparece — o erro não pode estar escondido atrás de um controle fechado.
  • A confirmação fica numa barra presa ao rodapé, acima do teclado, com o total à esquerda e o botão à direita. Sem isso, confirmar exige rolar até o fim de um formulário que o teclado encurtou pela metade.

O comprovante no celular é a câmera ou os arquivos do aparelho, pelo seletor nativo — tirar foto do comprovante na hora é o caso comum, e é o que essa ordem favorece. O campo de data usa o seletor de data nativo do aparelho, não um calendário desenhado na página: em 375px o calendário próprio disputa espaço com o teclado e perde.

7.3 Estados

A tela já trata vazio, carregando e erro, e a rota já é fechada por permissão. O que muda é que agora há três coisas na tela — indicadores, resumo e lista — e elas podem estar em estados diferentes ao mesmo tempo. A regra que as amarra: nenhuma das três mostra número velho enquanto as outras carregam, porque um total antigo ao lado de uma lista nova é pior que um total ausente.

Situação Indicadores Resumo Lista
Carregando esqueleto no lugar do valor, dentro do próprio cartão esqueleto de três linhas o esqueleto de hoje — linhas no desktop, cartões no mobile
Competência sem nenhuma cobrança zerados, com a mesma moldura some "Nenhuma cobrança nesta competência." e o caminho: a competência muda no filtro logo acima
Filtro sem resultado refletem o filtro: zerados some a mensagem de hoje, com "Limpar filtros"
Nada, nunca zerados some a mensagem de hoje — cobranças aparecem sozinhas quando uma garantia é assinada
Erro ao carregar somem some o alerta de hoje, com "Tentar de novo"
Mostrar removidos, sem nenhum removido seguem, contando o vigente segue "Nenhum registro removido", como já é hoje
Juros e carência ainda não configurados Em atraso no mês mostra o valor sem encargo, e o detalhe diz que o encargo não está parametrizado segue, com a coluna de encargo zerada a segunda linha da célula de Valor não aparece, sem o "+ multa" solto

As três áreas carregam separadas, e podem falhar separadas. A tabela acima trata o erro que derruba tudo — a lista não veio. Quando só o resumo ou só os indicadores falham, o que falhou mostra uma linha de erro no lugar dele, com "Tentar de novo" próprio, e o resto da tela continua servindo: a lista é a parte que sempre importa, e derrubá-la porque um total não chegou seria trocar uma perda pequena por uma grande.

O dia seguinte a esta spec vale para os quatro campos novos, e eles se comportam diferente. Juros e carência da multa nascem vazios e produzem o estado da última linha da tabela: a parte monetária do encargo fica de fora até alguém preencher. Meio de cobrança da garantidora nasce com Boleto (7.4) e por isso não tem estado de não configurado — sem ele, parcela nenhuma teria canal. A imobiliária recolhe as mensalidades nasce com Não em toda parceira existente, o que significa que, no dia seguinte, todas as mensalidades saem pelo meio da garantidora até alguém marcar a Solar; é o padrão seguro, porque errar para esse lado gera uma cobrança que a garantidora acompanha, e errar para o outro geraria uma que ela acha que a parceira está recolhendo.

O estado "juros e carência ainda não configurados" é o de toda garantidora no dia seguinte, então não é um canto raro. A tela não calcula encargo pela metade e não finge que não há atraso: a parcela vencida continua marcada como tal, o que falta é só a parte monetária do encargo. E o painel de pagamento, nesse estado, diz por que a linha de multa e juros está zerada e leva a Parâmetros — hoje o diálogo omite a multa em silêncio quando não consegue lê-la, que é exatamente o comportamento que esta spec substitui.

Por que o resumo some em vez de mostrar zeros. Uma tabela de combinações com tudo em zero ocupa espaço dizendo o que o indicador já disse em uma linha. Os indicadores ficam porque a moldura deles é a resposta ("previsto: R$ 0,00" é informação); o resumo desaparece porque ele só existe para decompor um total que não existe.

A aba Atrasados vazia é uma boa notícia, e o texto diz isso — "Nenhuma cobrança em atraso" em vez do vazio genérico, e sem botão de limpar filtro quando não há filtro posto. Quando há filtro de tipo ou canal posto, o texto volta a ser o de filtro sem resultado, com a saída.

A contagem do badge quando ela é zero: o badge some, na aba e no menu. Um "0" ao lado de "Atrasados" chama atenção para o que não precisa de atenção.

Exportação. Enquanto o arquivo é gerado, a ação do topo fica desabilitada e o rótulo diz que está gerando — é operação de servidor e pode demorar em competência cheia. Ao terminar, um aviso confirma que o arquivo foi gerado, com o nome dele; em 375px esse aviso é a única evidência que a pessoa tem, porque o download some na bandeja do sistema. Falhando, o aviso diz o que falhou e a ação volta a ficar disponível — sem arquivo pela metade.

O painel de pagamento é a quarta superfície, e tem estados próprios. Abrindo, a conta aberta chega com esqueleto enquanto o encargo é calculado — é conta de servidor, não de tela. Trocando a data, só a conta e o total sugerido recarregam; o que a pessoa já digitou fica. Confirmando, o botão desabilita e diz que está registrando, para não haver dois lançamentos do mesmo pagamento por duplo toque. Recusado pela API, o painel continua aberto com tudo preenchido e a mensagem no campo que causou a recusa — nunca um alerta genérico que obrigue a redigitar. O anexo falhando é o caso descrito em 7.1: o pagamento não vai pela metade, e a pessoa escolhe entre tentar de novo e confirmar sem ele.

Sem permissão não muda: quem não tem billings:list_all não vê o item no menu e recebe a página de acesso negado ao digitar a URL. Dentro da tela, quem não pode escrever continua sem as ações de escrita. A exportação segue a leitura, não a escrita — quem enxerga a competência inteira na tela pode levá-la num arquivo; exigir permissão de escrita para baixar o que já está à vista seria uma porta trancada ao lado de uma janela aberta.

Os quatro campos de configuração — três em Parâmetros, um na imobiliária — herdam o comportamento das telas onde entram: erro por campo abaixo do campo, e o envio recusado volta com a mensagem no lugar certo. Nada novo — e é por isso que não ganham tratamento próprio aqui.

7.4 As duas telas de configuração

Parâmetros ganha três campos, e nenhum painel novo.

  • Juros de mora (% ao mês), no painel Taxas, ao lado de Multa por atraso (%) — são o mesmo assunto e se preenchem na mesma sentada.
  • Carência da multa (dias), no painel Cobrança e sinistro. O rótulo precisa dessa precisão porque "Carência" já está ocupado naquele painel por Carência para sinistro (dias), que é outra coisa inteiramente; dois campos chamados "Carência" lado a lado, medindo coisas diferentes, é erro de preenchimento esperando acontecer.
  • Meio de cobrança da garantidora — boleto ou PIX —, também em Cobrança e sinistro, junto dos dias de vencimento, que é a mesma conversa sobre como ela cobra. É a primeira das duas escolhas da 7.B, e é dela que saem dois dos três valores de canal; sem esse campo, "Boleto da garantidora" e "PIX da garantidora" seriam inalcançáveis. Nasce com Boleto, que é o que a operação de hoje faz — a coluna da planilha chama-se "Boleto TXC". Ele é o único dos quatro campos novos que não pode ficar sem valor: dele depende o canal de toda parcela gerada, e uma parcela sem canal não entra em coluna, filtro nem resumo. Os outros três podem nascer vazios porque o que falta neles é uma parcela do cálculo, não a classificação da cobrança.

Nos dois viewports o painel já empilha os campos em uma ou duas colunas conforme a largura — não muda nada além de existirem mais três.

Imobiliárias ganha um campo no cadastro, no painel Dados gerais: A imobiliária recolhe as mensalidades — sim ou não, com Não como valor inicial. É a segunda escolha da 7.B, e é binária de propósito: sem terceira opção de "herdar", sem nada para interpretar. A entrada (setup e primeira parcela) não entra nessa escolha: ela sai sempre pelo meio da garantidora, e oferecer a opção sugeriria uma flexibilidade que não existe.

O campo existe na criação, não só na edição — é diferente do Status, que é o vizinho de grade dele e só aparece ao editar. O motivo é que a parceira nasce com um acordo de recolhimento já combinado; deixá-lo para uma segunda visita significaria que toda parceira nova passa um tempo com o padrão errado, e as cobranças geradas nesse meio-tempo nascem classificadas errado. Na criação ele ocupa o lugar que o Status deixa vago na grade; na edição, os dois convivem e a linha segue para a seguinte.

Na lista de imobiliárias o canal aparece como coluna própria no desktop, entre Telefone e Status — a tabela tem sete colunas (Empresa, CNPJ, Contato, Telefone, Status, Cadastrado em, Ações) e cabe mais uma; o lugar é esse porque agrupa com o Status os dois atributos de como a parceria opera. A coluna chama-se Mensalidades e mostra Recolhe ou travessão — não Canal, que seria enganoso, já que o que se registra ali é quem recolhe, não o meio. Não há terceiro valor a exibir, porque não há terceiro estado no campo.

No mobile, onde o cartão já usa as duas linhas de texto e a faixa de selos, o canal entra como selo ao lado do status — e só quando a parceira recolhe, com o rótulo Recolhe as mensalidades. A parceira que não recolhe é o caso comum e não precisa de selo para dizer que nada de especial acontece com ela. Essa assimetria também resolve o aperto: a faixa já pode ter dois selos quando a parceira está removida, e um terceiro a estouraria — e a removida, que saiu de operação, não é a que está recolhendo.

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 Nota Veredicto
1 0 8 4 7 Reprovada
2 1 7 6 7 Reprovada
3 3 4 5 6 Reprovada
4 3 6 7 7 Reprovada
5 0 2 7 8 Aprovada

Resultado: aprovada na rodada 5.

Triagem dos achados da rodada 5. Os dois Important e os sete Minor foram corrigidos antes de fechar — nenhum ficou em aberto: as colunas próprias da lista embutida no detalhe da garantia (que não pode herdar as fusões da tela agregada, por não ter as colunas que se fundem), a Competência que perde a opção "Todos" por ser o assunto da aba e não um recorte opcional, mais os sete acabamentos — a palavra "aba" usada para o que é seção, o comprovante sem célula no desktop, a leitura de ?status= que sai junto, "linhas fixas" contradizendo a frase seguinte, a ordenação descrita com uma chave a mais do que existe, o motivo errado para a Situação sair da aba Atrasados, e o rótulo da coluna nova em Imobiliárias.

Pendências aceitas: nenhuma.

Cinco rodadas, e vale registrar por quê. As notas foram 7, 7, 6, 7, 8, e as quatro primeiras acharam sempre a mesma classe — decisão de tela começada e não levada até o fim. Nenhuma das reprovas foi por falta de informação do usuário: das dez perguntas que o laço levantou, sete eu mesmo podia responder e não tinha respondido. A causa é o tamanho da parte: quatro superfícies, dois padrões que o app não tem (abas em tela e exportação de arquivo), uma aritmética de cinco indicadores e um formulário que cresce de um campo para quatro blocos. O usuário autorizou a quinta rodada sabendo do custo; ela fechou. Se uma Parte II voltar a ter esta forma, fatiar a spec antes do portão sai mais barato que cinco rodadas.


Parte III — Detalhamento

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

9. Regras de negócio

9.1 A classificação da cobrança

  • RN1 — Billing ganha tipo. Coluna fee_type, Enum(BillingFeeType, name="billing_fee_type"), nullable=False, com quatro valores: SETUP (só a taxa de adesão), GUARANTEE (só a fiança do mês), SETUP_AND_GUARANTEE (a entrada que junta os dois) e MISC (a avulsa, RN7). O enum e a coluna seguem o molde de BillingOrigin (apps/api/app/models/billing.py:51-64 e :125-130): classe str, enum.Enum com docstring por membro, name= explícito no Enum da coluna, default= em Python e server_default= em SQL.
  • RN2 — A entrada guarda os dois valores discriminados. Colunas setup_amount e guarantee_amount, Numeric(12, 2), nullable=True. Em SETUP_AND_GUARANTEE as duas são preenchidas; em SETUP só a primeira; em GUARANTEE só a segunda; em MISC nenhuma. Invariante, verificada no service em toda escrita: quando fee_type não é MISC, coalesce(setup_amount, 0) + coalesce(guarantee_amount, 0) == amount. É esta invariante que permite o resumo somar por tipo sem ratear (RN20), e é por isso que ela é regra e não convenção.
  • RN3 — Billing ganha canal. Coluna channel, Enum(BillingChannel, name="billing_channel"), nullable=False, com os três valores da Parte II 7.B: GUARANTOR_BOLETO, GUARANTOR_PIX e AGENCY_PIX. O nome channel é escolhido com a colisão à vista (Parte I, seção 1): ChannelType já existe em apps/api/app/models/channel_integration.py:9-12 para notificação, e Billing.origin (billing.py:51) já ocupou "origem". A desambiguação é o prefixo da tabela — BillingChannel contra ChannelType — e o fato de as duas nunca aparecerem no mesmo módulo.
  • RN4 — Billing ganha a parcela, e ela conta mensalidades. Colunas installment_number e installment_total, Integer, nullable=True. installment_total é o número de mensalidades do plano — os meses de vigência —, e installment_number é a posição da mensalidade que aquela cobrança carrega. Assim 5/12 significa o que a planilha sempre quis dizer: a quinta de doze mensalidades.

São nulas em duas situações, e pelo mesmo motivo — a cobrança não carrega mensalidade nenhuma: na avulsa (MISC) e na entrada de tipo SETUP puro, que existe quando charge_first_month_upfront está desligado e é só a taxa de adesão. Numerá-la criaria uma "parcela 1" que divide a data com a primeira mensalidade e não é uma mensalidade. - RN5 — reference_month passa a ser obrigatório, e o sentinela sem-competencia é revogado. A coluna (billing.py:121) vira nullable=False, e a migration preenche o que estiver vazio com o mês do próprio due_date. O filtro passa a validar o formato ^\d{4}-(0[1-9]|1[0-2])$ — o mês entre 01 e 12, não só dois dígitos, porque 2026-13 passaria pela forma e não casaria com nada, respondendo 422 INVALID_REFERENCE_MONTH a qualquer outro valor — inclusive ao sentinela antigo, que sem essa validação voltaria 200 com lista vazia em vez de dizer que o valor não existe mais. Isto revoga a RN8 da spec 0048 (docs/specifications/0048-gestao-de-cobrancas/specification.md:536-540), que criou o valor sentinela sem-competencia no filtro justamente porque a coluna era opcional. A razão da revogação é a Parte II: a competência passou a ter uma definição só, e uma cobrança fora de todo mês seria dinheiro que nenhum total conta. - RN6 — A geração do calendário passa a preencher os quatro campos. GuaranteeService._gerar_calendario_de_cobranca (apps/api/app/services/guarantee_service.py:705-747) continua criando as mesmas cobranças, nas mesmas datas e pelos mesmos valores — nada muda em quantidade nem em valor —, e passa a gravar: - na cobrança de entrada (:723-732): fee_type = SETUP_AND_GUARANTEE quando guarantee.charge_first_month_upfront é verdadeiro, SETUP quando é falso; setup_amount = guarantee.initial_fee; guarantee_amount = guarantee.first_month_upfront (que é zero com a flag desligada — apps/api/app/services/pricing.py:76-77, e aí a coluna fica nula, não zero); installment_number = 1 com a flag ligada, nulo com ela desligada (RN4); channel pela RN9; - nas mensais (:736-747): fee_type = GUARANTEE; guarantee_amount = amount; installment_number = k, que é o índice do laço existente (for k in range(primeira_mensal, duracao + 1), :735-736, com primeira_mensal = 2 quando a entrada embute o 1º mês e 1 quando não) — k já é o ordinal da mensalidade nos dois modos, e somar um a ele produziria 13/12; channel pela RN9. - em todas que têm parcela: installment_total = meses_de_vigencia(start_date, end_date), que é o duracao que o laço já calcula (:734). - RN7 — A cobrança avulsa. BillingService.create (apps/api/app/services/billing_service.py:57-80), que grava origin = MANUAL (:85), passa a gravar fee_type = MISC, channel pelo meio da garantidora (RN9, sem consultar a parceira), reference_month = o mês do due_date informado quando o corpo o omitir, e deixa parcela e composição nulas.

9.2 A configuração de onde o canal sai

  • RN8 — TenantSettings.collection_method. Enum(CollectionMethod, name="collection_method") com BOLETO e PIX, nullable=False, server_default="BOLETO". É o meio pelo qual a garantidora cobra quando é ela quem cobra. Nasce com BOLETO e não admite ficar vazio: dele depende o canal de toda parcela gerada (Parte II, 7.4). Entra nas três classes de apps/api/app/schemas/tenant_settings.py — Create (:46-164), Update (:167-239) e Response (:242-312) — no molde de charge_first_month_upfront (:120-123, :183, :257), e no json_schema_extra do Create (:50-66).
  • RN9 — De onde sai o canal de cada parcela. Regra única, aplicada na geração (RN6) e no lançamento avulso (RN7):
Parcela Canal gravado
Entrada (SETUP ou SETUP_AND_GUARANTEE) GUARANTOR_BOLETO ou GUARANTOR_PIX, conforme TenantSettings.collection_method
Mensal, parceira com collects_monthly falso, ou garantia sem imobiliária idem
Mensal, parceira com collects_monthly verdadeiro AGENCY_PIX
Avulsa (MISC) idem à entrada — nunca consulta a parceira

A entrada nunca consulta a parceira, e a garantia sem partner_agency_id (apps/api/app/models/guarantee.py:140-145, a coluna é anulável) cai no meio da garantidora. O canal é gravado no instante em que a parcela nasce e nunca recalculado — mudar a parceria depois não reescreve competência fechada. - RN10 — TenantSettings.interest_percent_month e late_fee_grace_days. Numeric(5, 2) e Integer, ambos anuláveis, no molde de faixa de late_fee_percent (schemas/tenant_settings.py:88-91, :172, :249): juros de 0 a 100, carência de 0 a 180. Anuláveis porque nascem sem valor em toda garantidora existente — é o estado do dia seguinte descrito na Parte II, 7.3.

Cuidado registrado, e resolvido na Fase 5: TenantSettingsService.update faz data.model_dump(exclude_none=True) (apps/api/app/services/tenant_settings_service.py:48), que descarta o null explícito — e para estes dois campos vazio é uma resposta ("não cobro esta parcela"), não "não mexi". A spec previa conviver com o limite, mandando enviar 0; a implementação mostrou que 0 e null não são o mesmo: 0 é multa de 0%, null é "não parametrizado", e é essa distinção que a CA36 exibe na tela. O serviço ganhou uma exceção por exclude_unset para os dois campos (apps/api/app/services/tenant_settings_service.py:59-62), e o description do endpoint diz isso. - RN10a — A política de encargo é visível ao canal, e isso é decisão. Os três campos da RN8 e da RN10 entram na TenantSettingsResponse sem máscara por perfil, e por isso chegam ao AGENCY_USER, que detém tenant_settings:read (apps/api/app/db/rbac_seed.py:329). É coerente com o domínio: quem cobra o inquilino é a imobiliária (CLAUDE.md, Domínio), e cobrar certo exige saber quanto ele deve com atraso. É também continuidade — late_fee_percent já sai para ela hoje (apps/api/app/schemas/tenant_settings.py:277), e os campos novos são da mesma natureza; mascarar três e deixar o quarto seria incoerência sem ganho.

A omissão de collects_monthly (RN11a) não é do mesmo tipo, e por isso convive com esta regra. Aquele campo não é política de cobrança do inquilino: é cláusula da parceria, e o que a omissão evita é a parceira editar o próprio acordo pela API. Que ela infira a cláusula pelo canal das próprias parcelas (AGENCY_PIX em cada mensalidade) é esperado e inofensivo — ela é quem recolhe, afinal. A omissão é de configuração, não de sigilo. - RN11 — PartnerAgency.collects_monthly. Boolean, nullable=False, server_default="false". Entra nas três classes de apps/api/app/schemas/partner_agency.py — Create (:11-44, com default False para não quebrar chamador existente), Update (:47-64) e Response (:67-110). O PartnerAgencyService.update é setattr cego sobre model_dump(exclude_none=True) (apps/api/app/services/partner_agency_service.py:123-124), então o campo passa a funcionar sem tocar o service — mas False e "não informado" se confundem no PATCH: exclude_none deixa False passar, o que é o comportamento desejado aqui. - RN11a — Só a garantidora decide se a parceira recolhe. collects_monthly é cláusula da parceria e não é aceito de quem não é do tenant: PartnerAgencyService.update admite ADMIN_GLOBAL (apps/api/app/services/partner_agency_service.py:118) e ele detém partner_agencies:update, então sem guarda ele mudaria por onde o dinheiro mensal de um cliente é cobrado — e, pela RN23, tiraria aquelas parcelas do alcance da rotina de atraso. Com quem chama sendo ADMIN_GLOBAL e o campo não nulo no corpo ⇒ 403 PLATFORM_ADMIN_CANNOT_SET_COLLECTION. "Não nulo" é a leitura que o service permite: ele só enxerga model_dump(exclude_none=True) (apps/api/app/services/partner_agency_service.py:120), então omitir o campo e enviá-lo nulo são indistinguíveis — e os dois passam, porque nenhum altera nada. Vale no create e no update.

9.3 Multa, juros e carência

  • RN12 — O encargo é calculado na API, não na tela. Função pura em apps/api/app/domain/encargo.py — arquivo novo, ao lado de periodo.py e guarantee_status.py —, recebendo valor, vencimento, data de referência e os três parâmetros do tenant, devolvendo multa e juros separados:
  • dias de atraso = data de referência − due_date, em dias corridos; zero ou negativo ⇒ encargo zero (pagamento adiantado ou em dia);
  • dentro da carência (dias ≤ late_fee_grace_days) ⇒ encargo zero;
  • fora dela: multa = amount × late_fee_percent / 100; juros = amount × interest_percent_month / 100 × dias / 30 (pro rata dia, mês comercial de 30 dias);
  • cada parcela é arredondada a duas casas, separadamente, com ROUND_HALF_UP;
  • parâmetro nulo zera a sua parcela e só a sua (RN10): sem juros configurados, a multa continua valendo; sem carência, ela vale como zero dias.
  • A carência conta a partir do vencimento, não do pagamento, e não reduz os dias de juros: passada a carência, os juros correm desde o vencimento. É a leitura usual de contrato de locação, e está dita aqui porque a alternativa (juros só depois da carência) dá números diferentes e ninguém saberia qual foi implementada.
  • RN13 — O encargo projetado viaja com a cobrança na leitura. GET /api/v1/billings, GET /api/v1/billings/{id}, GET /guarantees/{gid}/billings e GET /guarantees/{gid}/billings/{bid} passam a devolver late_fee_projected e interest_projected, calculados pela RN12 na data de hoje no fuso local (hoje_local, apps/api/app/domain/guarantee_status.py). São campos derivados, nunca persistidos — é o que permite a lista mostrar o encargo em valor (Parte II, 7.1, coluna 6) sem o front refazer conta nenhuma.

Os parâmetros saem do tenant da própria cobrança, não de quem chama. A lista agregada devolve linhas de mais de uma garantidora para o ADMIN_GLOBAL (10.5), e ele não tem tenant — sem esta regra o cálculo não teria de onde sair. A leitura carrega os TenantSettings dos tenants presentes na página em lote, numa consulta, e aplica a cada linha o do dono dela. Tenant sem parâmetro configurado produz a parcela correspondente em zero (RN12), nunca erro. - RN14 — A conta aberta numa data escolhida tem endpoint próprio. GET /api/v1/guarantees/{gid}/billings/{bid}/charge?on=YYYY-MM-DD devolve a decomposição na data pedida: principal por tipo, multa, juros, total. Existe porque o painel recalcula ao mudar a data (Parte II, 7.1) e porque o cálculo é da API (RN12). on ausente vale hoje; on no futuro ⇒ 422 CHARGE_DATE_IN_FUTURE.

9.4 O registro do pagamento

  • RN15 — BillingPayRequest cresce, e tudo que ele traz é persistido. Ao amount_paid de hoje (apps/api/app/schemas/billing.py:45-50) somam-se: paid_on (data, obrigatória), payment_channel (BillingChannel, obrigatório), setup_paid/guarantee_paid/misc_paid/charge_paid (a distribuição, Numeric(12,2), obrigatórias), waive_late_fee (bool, padrão falso), waive_reason (texto, opcional) e notes (texto, opcional).

Billing ganha seis colunas para recebê-los — paid_on (Date), payment_channel (Enum(BillingChannel)), setup_paid, guarantee_paid, misc_paid e charge_paid (Numeric(12,2)), todas anuláveis, preenchidas na baixa. misc_paid é o principal da avulsa, que não tem setup nem fiança (RN2): sem ele, o pagamento de uma cobrança MISC não teria balde de principal, e o recebido da RN27 a perderia. Sem elas os agregados da RN26 e da RN27 não teriam de onde sair: pago por linha de tipo × canal, recebido só do principal e encargo_recebido são exatamente a distribuição somada, e amount_paid sozinho não a decompõe.

Os três que não viram coluna, e onde ficam. waive_late_fee e waive_reason vão só para a auditoria (RN22) — o efeito deles já está no resultado, que é charge_paid zerado, e duplicá-lo em coluna criaria duas fontes para o mesmo fato. notes grava na coluna notes que já existe (apps/api/app/models/billing.py:201), acrescentando ao que houver, não substituindo — ela é omitida da imobiliária (apps/api/app/schemas/billing.py:456-471) e isso continua valendo.

payment_channel é registro, não classificação. Ele diz por onde o dinheiro entrou; o resumo (RN26) continua agregando pelo channel da cobrança, que é a previsão. Divergir entre os dois é normal — boleto emitido, pago por PIX — e não move número de resumo nenhum; a divergência se lê na própria parcela (Parte II, 7.B).

paid_on e paid_at convivem e medem coisas diferentes. paid_at (apps/api/app/models/billing.py:200) continua sendo o instante do registro, gravado pelo sistema; paid_on é a data em que o dinheiro entrou, informada por quem registra, e é ela que a RN12 usa para calcular o encargo e a RN26 para atribuir a competência do recebimento. Lançar hoje um pagamento de ontem tem de produzir o encargo de ontem — é o caso que a Parte I descreve no passo 6. - RN16 — A distribuição precisa fechar, e cada balde tem piso e teto. Três condições, todas com 422 PAYMENT_ALLOCATION_MISMATCH e a violação nomeada no corpo do erro: 1. Soma: setup_paid + guarantee_paid + misc_paid + charge_paid == amount_paid. 2. Piso: nenhum dos quatro é negativo — sem isso, compensar um balde com outro fecharia a soma e falsearia o resumo. 3. Teto por balde: setup_paid ≤ setup_amount, guarantee_paid ≤ guarantee_amount, misc_paid ≤ amount na avulsa, e charge_paid ≤ o encargo devido na data (RN12), ou zero sob abono (RN18). Balde cuja coluna de composição é nula tem teto zero.

O teto é a condição que faltava para a identidade da RN27 fechar "por construção". Sem ele, uma entrada de R$ 714,04 (setup 300,00 + fiança 414,04) paga integralmente com setup_paid = 714,04 e guarantee_paid = 0 passa nas outras duas condições — e o resumo mostra a linha de SETUP com pago maior que cobrado, e a de GUARANTEE com pago zero. O relatório por tipo divergindo da composição é o defeito que esta spec existe para acabar, reintroduzido pela porta do formulário. - RN17 — Data de pagamento não pode ser futura. paid_on > hoje_local() ⇒ 422 PAYMENT_DATE_IN_FUTURE. Data anterior ao vencimento é válida — é pagamento adiantado, e a RN12 já devolve encargo zero para ela. - RN18 — Abono exige motivo. waive_late_fee verdadeiro sem waive_reason não vazio ⇒ 422 WAIVER_REASON_REQUIRED. Com abono, charge_paid precisa ser zero, senão 422 PAYMENT_ALLOCATION_MISMATCH — abonar e cobrar encargo na mesma operação é contradição. O motivo vai para a auditoria (RN22). - RN19 — Recebido a menos, e recebido a mais. Principal recebido abaixo do devido: a cobrança vai a PAID, e a diferença é gravada em discount_amount (Numeric(12,2), anulável) — o abatido da Parte II, 7.1. A diferença se mede sobre o principal alocado, não sobre amount_paid: discount_amount = amount − (setup_paid + guarantee_paid + misc_paid), quando positivo. Medi-la por amount_paid deixaria escapar o caso em que se paga o valor cheio do boleto e parte dele é alocada em encargo — o principal recebido fica abaixo do previsto, e a identidade da RN27 não fecharia. amount_paid maior que o total devido na data (principal + encargo, ou principal apenas sob abono) ⇒ 422 PAYMENT_EXCEEDS_DUE, com o teto no corpo: o produto não tem conceito de crédito a favor do inquilino, e inventar um aqui criaria saldo que nada consome. - RN20 — O comprovante é um Document com FK própria. Document ganha a quinta FK polimórfica, billing_id → billings.id, ondelete="SET NULL", anulável e indexada, ao lado das quatro de apps/api/app/models/document.py:15-26. Entram junto: o ramo em Document.entity_type (:65-75) e em Document.entity_id (:79), "billing" como tipo aceito — e aqui há um detalhe que decide o desenho: _VALID_ENTITY_TYPES é derivado de _ENTITY_REPOS (document_service.py:87, que era set(_ENTITY_REPOS.keys())), e billing não pode entrar no segundo (ver abaixo). A derivação deixa de existir: _VALID_ENTITY_TYPES passa a ser uma constante própria, com os quatro tipos de hoje mais billing, e _ENTITY_REPOS continua sendo só o mapa do caminho genérico. Sem isso o upload cairia em INVALID_ENTITY_TYPE (document_service.py:108-112) — falha fechada, mas falha. Entram também o kwargs do upload (:148-153), o col_map e o _owned_by_agency de apps/api/app/repositories/document_repo.py:17-53,78-84, e a lista do Form(...) em apps/api/app/api/v1/routers/documents.py:80.

A resolução do billing_id não pode passar por resolve_in_scope, e é preciso dizer por quê — a alternativa é a implementação descobrir sozinha, no meio do código. resolve_in_scope (apps/api/app/services/scoping.py:151-188) chama repo.get_by_id(id, tenant_id) (:183): o repositório não recebe o usuário, então não tem como aplicar escopo de imobiliária. E logo depois ela roda visible_to (:186), que lê record_agency_id(record) — getattr(record, "partner_agency_id", None) (:132-135) — e devolve None para Billing, fazendo within_agency recusar toda imobiliária (:96-99), inclusive a dona da garantia. Sobrescrever get_by_id não conserta nem uma coisa nem outra.

_ENTITY_REPOS também não serve, e é preciso dizer: ele é um mapa de (módulo, classe) resolvido por importlib dentro de _validate_entity (apps/api/app/services/document_service.py:749-755), que só sabe instanciar um repositório e chamar resolve_in_scope sobre ele. E BillingService._get_billing (apps/api/app/services/billing_service.py:780-795), que resolve certo, exige um guarantee_id que o upload nunca recebe — o Form traz só entity_type e entity_id (apps/api/app/api/v1/routers/documents.py:80-90).

O mecanismo é este, e é um só. BillingRepository ganha get_in_scope(billing_id, current_user): uma consulta que junta Guarantee e aplica os dois escopos no mesmo where — Billing.tenant_id == tenant_scope(user) quando não nulo, e Guarantee.partner_agency_id == agency_scope(user) quando não nulo —, como list_cross_guarantee já faz (apps/api/app/repositories/billing_repo.py:214-221); não encontrando, NotFoundError ⇒ 404. E _validate_entity ganha um ramo explícito para billing, antes do caminho genérico: chama esse método e retorna, sem passar por _ENTITY_REPOS nem por resolve_in_scope. billing não entra em _ENTITY_REPOS, porque o mapa pressupõe o caminho genérico que aqui não vale.

É essa resolução que faz a distinção das duas recusas existir. A imobiliária dona da garantia encontra a cobrança e é barrada depois, pela guarda de escrita, com 403 (RN20a); a de outra carteira não encontra, e recebe 404. É a distinção entre você não escreve aqui e isto não existe para você, e ela só se sustenta pela ordem — resolver antes de autorizar (document_service.py:137-138) —, que é também o que impede o 403 de virar oráculo de existência.

O escopo de leitura passa pela mesma junção. O ramo novo de _owned_by_agency (apps/api/app/repositories/document_repo.py:17-53) alcança Guarantee.partner_agency_id pela cobrança. Sem ele o comprovante seria o único documento sem dono alcançável, contra a RN13 da spec 0034. E os dois col_map são tocados, não um: o de list_by_entity (apps/api/app/repositories/document_repo.py:78-84) e o de count_by_entity (:169-176), que indexa sem .get — esquecê-lo dá KeyError no primeiro upload e desliga o teto de 20 documentos por entidade.

Por que não pendurar na garantia. Porque o dossiê fecha quando a garantia fica ACTIVE (document_service.py:223, spec 0040) — que é exatamente o estado em que se paga —, e anexar exigiria guarantees:dossier_attach, permissão que a 0040 restringiu de propósito. O operador financeiro não a tem, e dá-la para ele abriria o dossiê assinado.

O upload segue o caminho que já existe, com as validações que já existem (document_service.py:108-155): tipo em settings.ALLOWED_CONTENT_TYPES (apps/api/app/core/config.py:122-153), teto de MAX_UPLOAD_SIZE_MB = 10 (:121), recusa de PDF truncado e teto de 20 documentos por entidade.

  • RN20a — Quem pode anexar comprovante, e as duas guardas que faltam. O comprovante é parte do ato de dar baixa, não um documento avulso da cobrança. Por isso toda escrita sobre um documento cujo dono é uma cobrança exige, além da permissão de documento correspondente, a permissão billings:update — conferida no service com exigir_permissao (apps/api/app/core/dependencies.py:83-102), porque depende do tipo da entidade dona e não se resolve no Depends do router.

São quatro caminhos, não um, e citá-los todos é o ponto desta regra: o upload, a edição de metadado, a substituição de conteúdo e a remoção. O OPERATOR ilustra por que a lista precisa ser completa: ele tem documents:update e documents:delete (apps/api/app/db/rbac_seed.py:361) e não tem billings:update (:359) — guardando só o upload, ele não anexaria comprovante nenhum e apagaria os dos outros. As duas guardas abaixo entram em funções já chamadas nos quatro caminhos (apps/api/app/services/document_service.py:343,347,481,485,556,560); a exigência de billings:update precisa acompanhá-las nos mesmos quatro.

Isso alinha a matriz 10.1: quem não registra pagamento não anexa, não edita e não apaga comprovante.

Duas guardas explícitas entram em DocumentService, e nenhuma das duas existe hoje:

  • O SaaS admin é recusado. _garantir_que_o_dossie_aceita_escrita retorna cedo para qualquer entity_type que não seja guarantee (apps/api/app/services/document_service.py:264), antes do PLATFORM_ADMIN_CANNOT_AMEND de :265-272 — e por isso a guarda nova não mora naquela função: ela é uma checagem própria, chamada nos quatro caminhos de escrita, em vez de depender da ordem interna de uma função que trata de dossiê. Sem isso o ADMIN_GLOBAL atravessaria e tentaria gravar um Document com tenant_id nulo, produzindo erro de integridade em vez de 403. A guarda nova recusa com 403 PLATFORM_ADMIN_CANNOT_ATTACH_BILLING_RECEIPT, no mesmo ponto e pelo mesmo motivo que BillingService._garantir_que_nao_e_o_saas_admin recusa a baixa (billing_service.py:102-121). A subtração de billings:update no mapa (spec 0048, RN9) é reforço; quem recusa é esta guarda, porque a fixture de teste do ADMIN_GLOBAL concede todas as permissões sem consultar o mapa.
  • A imobiliária é recusada. _garantir_que_a_imobiliaria_pode_escrever ramifica em garantia, análise, inquilino e imóvel e cai num else: return (document_service.py:289-290) — ou seja, libera o que não conhece, que é exatamente o defeito registrado no docstring de :282. Ganha ramo próprio para billing, que recusa com 403 AGENCY_CANNOT_WRITE_BILLING_DOCUMENT quando quem chama tem partner_agency_id preenchido. A imobiliária lê o comprovante da cobrança da própria garantia (10.4) e não escreve nela — é a mesma fronteira que a spec 0038 fixou para a garantia, que é da garantidora.
  • RN20b — Uma cobrança tem no máximo um comprovante. O campo da resposta chama-se receipt_document_id, no singular, e é isso mesmo: anexar um segundo substitui o primeiro, que é removido logicamente. O teto de 20 documentos por entidade (document_service.py:150-155) continua valendo como rede, mas nunca é alcançado por este caminho. A alternativa — vários comprovantes por cobrança — pediria uma tela para escolher entre eles, que a Parte II não desenhou.
  • RN21 — O pagamento não é registrado pela metade. O comprovante é um segundo passo, não um campo do pay: a tela envia o pagamento e, tendo arquivo, o anexa em seguida pelo endpoint de documentos. Falhando o anexo, o pagamento já está gravado e a tela diz isso, oferecendo tentar o anexo de novo — em vez de desfazer uma baixa que a contabilidade já pode ter visto. É divergência consciente do que a Parte II descreve como "o pagamento não vai pela metade": o que não vai pela metade é o pagamento; o anexo é acessório e pode chegar depois.
  • RN22 — Auditoria do pagamento. A chamada de hoje (billing_service.py:277-282, action="PAID") passa a levar no payload a data, o canal de entrada, a distribuição, o abono e o motivo. Abono sem registro de quem e por quê é renúncia de receita invisível.

9.5 O atraso

  • RN23 — Rotina diária que marca o atraso. Segundo job cron no worker arq (apps/api/app/worker.py:149-158), às 7h no fuso local — uma hora depois do daily_credit_finance_job que já existe (:102), para os dois não disputarem banco no mesmo minuto —, declarado com timeout=600 e run_at_startup=False, como o vizinho. Ele marca OVERDUE toda cobrança que: está PENDING, tem due_date anterior a hoje, não está removida (deleted_at is NULL) e tem channel em GUARANTOR_BOLETO ou GUARANTOR_PIX. AGENCY_PIX fica de fora (Parte II, seção 5): sem saber se a parceira recebeu, marcar seria afirmar inadimplência de quem talvez esteja em dia.

A lista de tenants sai das próprias cobranças — os tenant_id distintos das cobranças PENDING vencidas e não removidas —, não de uma tabela de configuração. O job vizinho itera quem tem token de consulta (worker.py:78-83) porque é disso que ele trata; aqui, garantidora sem cobrança vencida não tem trabalho a fazer.

Segue o padrão do job existente — uma sessão e uma transação por tenant, except Exception largo com rollback, contador e logger.warning, sem re-raise (worker.py:92-96): a falha de uma garantidora não aborta as outras. Devolve o consolidado. Não entra em functions, que é só para job enfileirado. - RN24 — A situação é derivada, o status continua gravado. A resposta de cobrança ganha situation, campo derivado com cinco valores: PAID e CANCELLED quando o status é esse; OVERDUE quando o status é OVERDUE; AWAITING_AGENCY quando a cobrança está PENDING, venceu e tem channel = AGENCY_PIX; PENDING no resto. É o que a coluna e o filtro de Situação da Parte II mostram, e é o que dá nome à diferença entre a tela e o indicador de inadimplência da home. BillingStatus não ganha valor novo — a máquina de estados fica como está, o que preserva a decisão de não criar estado sem saída (Parte I, seção 5). - RN24a — O filtro de situação. GET /api/v1/billings ganha situation, com os cinco valores da RN24, substituindo o filtro status de hoje (apps/api/app/services/billing_service.py:384-428) — os dois na mesma tela ofereceriam recortes que se contradizem. A tradução para consulta é fechada aqui, porque situation é derivada e não existe como coluna: PAID, CANCELLED e OVERDUE filtram pelo status homônimo; AWAITING_AGENCY filtra status = PENDING e due_date < hoje e channel = AGENCY_PIX; PENDING filtra status = PENDING e o complemento disso. - RN25 — A fila de atrasados recorta pelo fato. GET /api/v1/billings ganha o filtro overdue_only, que devolve o que venceu e não foi pago — status em (PENDING, OVERDUE) com due_date < hoje —, ordenado por due_date crescente (o mais vencido primeiro) e sem filtro de competência. A contagem correspondente alimenta o badge e o rótulo da aba.

A fila inclui duas situações além de OVERDUE. Uma é a AWAITING_AGENCY; a outra é a cobrança de canal da garantidora que venceu hoje de madrugada e ainda aparece como Pendente, porque a rotina só roda às 7h (RN23). As duas entram pelo mesmo critério — o fato de ter vencido — e é justamente por isso que a fila recorta pelo fato e não pelo status: uma fila que só mostra o que já foi carimbado chega sempre um dia atrasada.

Sobre a AWAITING_AGENCY, ainda: a RN24 a separa de propósito de inadimplência, e isso não contradiz a inclusão dela aqui. A aba é fila de trabalho — o que venceu e precisa de atenção —, e a parcela que a parceira recolheu e não repassou precisa. O que não se faz é chamá-la de inadimplente: na coluna de situação ela continua Aguardando a imobiliária, e é por isso que o indicador de inadimplência da home, que lê o status gravado, não a conta.

9.6 Os agregados

  • RN26 — O resumo por tipo e canal. GET /api/v1/billings/summary, parametrizado pela competência e pelos mesmos filtros da lista, devolve uma linha por combinação de fee_type × channel presente na competência, mais o total. Por linha: cobrado, pago, abatido, a receber, encargo recebido e parcelas.
  • A entrada é decomposta: setup_amount soma na linha de SETUP, guarantee_amount na de GUARANTEE, ambas no canal dela. É a RN2 que torna isso possível sem ratear.
  • Na contagem, a entrada conta uma vez, na linha de SETUP; o total conta parcelas distintas, e por isso é a soma das linhas.
  • MISC forma a linha Avulsas, sempre a última antes do total.
  • Removidas e canceladas nunca entram — deleted_at is NULL e status != CANCELLED. A exclusão da removida é da Parte II, 7.1; a da cancelada é o que faz o previsto bater com o da Central de Operações, que já a exclui (apps/api/app/services/operations_center_service.py:568) — sem isso a CA26 falharia em toda competência que tivesse um cancelamento, sem ninguém ter errado.
  • RN26a — O resumo e a exportação exigem tenant. Os dois recusam o ADMIN_GLOBAL com 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS — guarda nova, no mesmo molde e pelo mesmo motivo de _garantir_que_nao_e_o_saas_admin (billing_service.py:102-121), mas com código próprio: ela recusa agregar, não liquidar. A condição é current_user.is_admin_global, o mesmo predicado que a guarda vizinha usa — e não "não tem tenant", para que um perfil futuro sem tenant não atravesse por omissão. É restrição nova, e deliberada: a spec 0048 concedeu a ele a lista paginada cross-tenant, e isso fica; somar num único indicators o previsto, o recebido e o a receber de todas as garantidoras é um retrato financeiro da carteira de cada cliente, e levá-lo num arquivo é a mesma coisa em pior. A rubrica põe cobrança entre os dados de negócio do cliente que não atravessam a fronteira plataforma × cliente; ler linha a linha uma tela paginada é onde a 0048 parou, e é onde esta spec mantém a porta.
  • RN27 — Os indicadores da competência, com as fórmulas. O mesmo endpoint devolve seis números — quatro formam a identidade, um é derivado dela e o sexto é um recorte. Sobre as cobranças da competência não removidas e não canceladas (RN26):
Indicador Fórmula
previsto Σ amount
recebido Σ (setup_paid + guarantee_paid + misc_paid) — só principal, nunca charge_paid
abatido Σ discount_amount
a_receber_garantidora Σ amount das não pagas com channel em GUARANTOR_BOLETO/GUARANTOR_PIX
a_repassar_imobiliaria Σ amount das não pagas com channel = AGENCY_PIX
em_atraso_no_mes Σ amount das não pagas com due_date < hoje, mais a contagem delas e o encargo corrido

A identidade previsto = recebido + abatido + a_receber_garantidora + a_repassar_imobiliaria fecha por construção: toda cobrança paga contribui com recebido + abatido = amount (RN19 garante que a diferença vai para discount_amount), e toda não paga contribui com amount para exatamente um dos dois últimos, conforme o canal. Nada disso toca encargo — multa e juros não estão previstos, e somá-los faria uma competência de inadimplentes fechar com recebido maior que previsto. charge_paid alimenta só a coluna encargo recebido do resumo.

A baixa anterior a esta spec é reconstruída na leitura (decisão do usuário, 2026-09-22, na revisão da Fase 4). Ela tem amount_paid e os quatro baldes nulos — a migration não fez passo de dados, porque não havia como inventar a divisão de um pagamento que ninguém decompôs. Somá-la como recebido = 0 quebrava a identidade pelo valor inteiro da parcela. Para ela, e só para ela: recebido += min(amount_paid, amount) e o excedente sobre o amount vai para encargo_recebido — o que entrou até o valor da parcela é principal, e o que passou dele só pode ter sido encargo.

Por consequência, o abatido passa a ser medido em vez de lido: Σ (amount − principal recebido), quando positivo. As duas definições coincidem para a baixa de hoje, já que é a RN19 que grava discount_amount com exatamente essa diferença; e só a medida funciona para a legada. Medir nos dois lugares é também o que impede o indicador de discordar da linha da tabela logo abaixo, que sempre foi medida — duas somas da mesma coisa na mesma tela, que é a divergência que esta spec existe para acabar.

Numa entrada composta legada, o principal reconstruído é alocado na ordem da composição: preenche a adesão até o teto dela, o resto vai para a fiança. É a única ordem que não estoura nenhum dos dois tetos. Nada disso é gravado — o backfill na migration foi considerado e recusado, porque escrever nas colunas um número que ninguém registrou apagaria a diferença entre "foi assim" e "deduzimos que foi assim".

em_atraso_no_mes conta pelo fato, não pelo status gravado — due_date < hoje e não paga, qualquer que seja o canal —, igual à aba Atrasados (RN25) e por coerência com ela: na mesma tela, dois recortes de "atraso" com critérios diferentes seriam a divergência que esta spec existe para acabar. Ele atravessa os dois últimos da identidade, e por isso fica fora dela.

O reference_month é obrigatório neste endpoint — sem competência não há o que somar; ausente ⇒ 422 REFERENCE_MONTH_REQUIRED. overdue_only não vale aqui: a fila não é uma competência (Parte II, 7.1). - RN28 — A Central de Operações passa a recortar o mês pela competência. OperationsCenterService._monthly_financial (apps/api/app/services/operations_center_service.py:541-586) troca o par de condições Billing.due_date >= first_day / <= last_day (:552-553) por Billing.reference_month == month_str — a variável já existe em :542. É a correção da divergência descrita na Parte I: as duas somas do produto passam a recortar o mês pela mesma definição.

A convergência é do recorte, não de todas as medidas, e isso precisa estar escrito. O received da Central é Σ amount_paid sobre as pagas (apps/api/app/services/operations_center_service.py:572-575) e inclui encargo; o recebido da RN27 é só principal. Numa competência com multa recebida os dois números seguem diferentes, e é correto que sigam: a home responde "quanto dinheiro entrou" e o resumo responde "quanto do previsto foi realizado", que é a pergunta do relatório. Esta spec não unifica as duas medidas — unifica o mês. O que a CA26 confere, e só, é o previsto. A RN5 (competência obrigatória) é o que torna a troca segura, porque sem ela a comparação excluiria em silêncio toda cobrança sem mês.

O description do endpoint documenta hoje a fórmula de default_rate (apps/api/app/api/v1/routers/operations_center.py:146) e não diz por qual data o mês é recortado — passa a dizer, no mesmo diff, porque a resposta muda para quem já usa o endpoint. Regra do apps/api/CLAUDE.md. - RN29 — A exportação da competência. GET /api/v1/billings/export, com os mesmos filtros da lista, devolve uma planilha .xlsx com duas abas — o resumo da RN26 e a lista de parcelas — e Content-Disposition: attachment com o nome trazendo a competência. Na fila de atrasados (overdue_only) devolve uma aba, a lista, e o nome traz a data de geração. - Valores monetários saem como número com formato de moeda, não texto. - Removidas nunca entram, pelo mesmo motivo da RN26. - Os bytes são materializados antes do retorno, num Response comum como o download de documento (apps/api/app/api/v1/routers/documents.py:180-185) — nunca StreamingResponse. Sob CommitRoute o corpo de um streaming seria produzido depois do commit e com a sessão já fechada (apps/api/app/core/transaction.py:71-104).

9.7 O que esta spec revoga

  • A RN8 da spec 0048 — o sentinela sem-competencia do filtro de competência —, pela RN5.
  • A leitura de ?status= na montagem da tela de Cobranças, pela Parte II, 7.A: o alerta da Central passa a apontar para a aba. A remoção fica registrada no lugar onde a leitura estava (apps/web/src/pages/billings/index.tsx:178-180).
  • A opção fixa "Sem competência" do filtro — a constante, a injeção no seletor e o rótulo do chip, os três pontos de apps/web/src/pages/billings/index.tsx —, pela RN5: deixada ali, ela pediria um valor que a API passa a recusar com 422. E, do lado da API, o ramo que a implementava em apps/api/app/repositories/billing_repo.py — que sem a coluna anulável deixa de ter o que casar, e que esta implementação removeu (o comentário que conta a remoção está no lugar dele).
  • A menção ao sentinela nas descrições Swagger de GET /billings (apps/api/app/api/v1/routers/billing.py:48,60) e de GET /billings/reference-months (:96-97), que passariam a documentar um valor inexistente.

Autorização tem seção própria (10).

10. Autorização e isolamento

Seção obrigatória. É onde se decide, antes de existir código, quem alcança o quê — e é o que o portão da seção 16 mede com mais rigor. Silêncio aqui não é "o padrão": o BaseRepository filtra por tenant sozinho, mas não resolve FK vinda do corpo, não escolhe entre 404 e 403, e não filtra nada para o ADMIN_GLOBAL.

10.1 Matriz de papéis

Papel Pode Não pode
ADMIN_GLOBAL Ler a lista agregada, de todos os tenants — só isso, e é o que a spec 0048 já decidiu (rbac_seed.py:224-225); ver os três campos de Parâmetros e o da imobiliária na leitura O resumo agregado (RN26a) e exportar (RN29): as duas formas de levar a carteira inteira de um cliente de uma vez; registrar pagamento (já recusado, spec 0048 RN9, billing_service.py:102-121); anexar comprovante (RN20a); alterar Parâmetros (PATCH /settings exige require_system_role("ADMIN_TENANT"), apps/api/app/api/v1/routers/tenant_settings.py:115); alterar collects_monthly (RN11a)
ADMIN_TENANT Tudo no próprio tenant: ler, exportar, registrar pagamento com distribuição e abono, anexar comprovante, configurar os três campos de Parâmetros e o campo da parceira —
ANALYST Ler a lista, o resumo, os indicadores e a fila de atrasados; baixar o arquivo Registrar pagamento, abonar, anexar comprovante, abrir a conta em data escolhida (/charge), marcar inadimplente, configurar
OPERATOR Idem ANALYST Idem ANALYST — inclusive anexar comprovante, que a RN20a passou a exigir billings:update, permissão que ele não tem (rbac_seed.py:359)
VIEWER Idem ANALYST Idem ANALYST
AGENCY_USER / AGENCY_MANAGER Ler cobrança dentro do detalhe da própria garantia, como hoje, agora com tipo, parcela, canal e o encargo projetado dela; ver o comprovante da própria cobrança Alcançar GET /billings, /billings/summary, /billings/export — não recebe billings:list_all nem billings:export (rbac_seed.py:422-427); a conta aberta (/charge, que exige billings:update, RN14); registrar pagamento; anexar comprovante (RN20a); ver ou editar o collects_monthly da própria imobiliária

Esta spec acrescenta quatro restrições ao ADMIN_GLOBAL — os dois agregados (RN26a), o anexo de comprovante (RN20a) e o collects_monthly (RN11a) —, e todas pelo mesmo raciocínio. A primeira delas é a que mais merece explicação:

Sobre a exportação, os agregados e o ADMIN_GLOBAL. Ele lê a lista agregada de todos os tenants por desenho herdado de guarantees:list, e a 0048 registrou isso. Uma planilha com a competência inteira de um cliente é outra coisa: é extração em massa, num arquivo que sai do produto e que o SaaS admin não tem motivo de negócio para ter. A permissão nova billings:export não lhe é concedida, pelo mesmo raciocínio e no mesmo lugar em que a 0040 subtraiu o dossiê e a 0048 subtraiu billings:update (rbac_seed.py:205-237). E, como naqueles casos, a subtração no mapa é reforço — quem recusa de fato é uma guarda no service, porque a fixture de teste do ADMIN_GLOBAL concede todas as permissões sem consultar o mapa.

Sobre collects_monthly e o AGENCY_USER. A imobiliária não vê nem altera o campo que diz se ela recolhe: é cláusula da parceria, decidida pela garantidora. O campo sai da resposta de GET /partner-agencies/{id} quando quem chama tem partner_agency_id preenchido — mesmo padrão de omissão por perfil que BillingAgencyResponse já usa para notes (apps/api/app/schemas/billing.py:456-471) e que TenantSettingsService._to_response usa para mascarar preço de consulta (tenant_settings_service.py:191-208).

10.2 Permissões

Endpoint Permissão Nova?
GET /api/v1/guarantees/{gid}/billings (resposta com os campos novos) billings:list Não
GET /api/v1/guarantees/{gid}/billings/{bid} (idem) billings:read Não
GET /api/v1/billings (com overdue_only, fee_type, channel, situation) billings:list_all Não
GET /api/v1/billings/{id} (resposta com os campos novos) billings:list_all Não
GET /api/v1/billings/summary billings:list_all e ter tenant (RN26a) Não
GET /api/v1/billings/export billings:export Sim
GET /api/v1/guarantees/{gid}/billings/{bid}/charge billings:update Não
POST /api/v1/guarantees/{gid}/billings/{bid}/pay (corpo maior) billings:update Não
POST /api/v1/guarantees/{gid}/billings (avulsa) billings:update Não
POST /api/v1/documents com entity_type=billing documents:create e billings:update (RN20a) Não
PATCH /api/v1/documents/{id} de comprovante documents:update e billings:update (RN20a) Não
PUT /api/v1/documents/{id}/content de comprovante documents:update e billings:update (RN20a) Não
DELETE /api/v1/documents/{id} de comprovante documents:delete e billings:update (RN20a) Não
GET /api/v1/documents/{id}/download (comprovante) documents:read Não
GET /api/v1/documents?entity_type=billing (o col_map novo) documents:list Não
GET /api/v1/documents/storage-summary (a contagem passa a incluir comprovante) require_system_role("ADMIN_TENANT") — papel, não permissão (apps/api/app/api/v1/routers/documents.py:122) Não
GET /api/v1/settings (três campos novos na resposta, sem máscara por perfil — RN10a) tenant_settings:read Não
PATCH /api/v1/settings (três campos novos) require_system_role("ADMIN_TENANT") Não
POST /api/v1/onboarding/partner-agencies (campo novo) partner_agencies:create Não
PATCH /api/v1/partner-agencies/{id} (campo novo) partner_agencies:update Não
GET /api/v1/operations-center/financial-summary (recorte novo, RN28) operations_center:read Não

billings:export é declarada em _PERMISSIONS (apps/api/app/db/rbac_seed.py:97-113, ao lado de billings:list_all em :108) e concedida a ADMIN_TENANT (:281), ANALYST (:329), OPERATOR (:359) e VIEWER (:379) — não a ADMIN_GLOBAL nem a AGENCY_USER.

Ela é de leitura, e é de propósito que não exija escrita. Quem enxerga a competência inteira na tela pode levá-la num arquivo; exigir billings:update para baixar o que já está à vista seria porta trancada ao lado de janela aberta. O que a permissão separa não é "ler" de "escrever" — é "ler na tela, paginado" de "levar tudo embora num arquivo", e é essa segunda capacidade que o ADMIN_GLOBAL não recebe.

A rotina diária (RN23) não tem permissão, e isso é dito de propósito: ela roda no worker, sem usuário, e por isso não passa por require_permission nenhum. O que a contém é o escopo: ela itera tenant a tenant, numa transação por tenant (apps/api/app/worker.py:92-96), e nunca faz consulta cross-tenant. Um job que lesse Billing sem tenant_id no where seria o equivalente silencioso de um endpoint sem porteiro.

10.3 Isolamento por garantidora (tenant)

  • Os endpoints novos herdam o filtro que já existe. GET /billings/summary e GET /billings/export reusam o caminho de BillingService.list_all (apps/api/app/services/billing_service.py:384-428), que passa tenant_scope(current_user) ao repositório; BillingRepository aplica if tenant_id is not None: condicoes.append(Billing.tenant_id == tenant_id) (apps/api/app/repositories/billing_repo.py:214-217). Nenhum dos dois monta consulta própria — é o que impede que o filtro seja esquecido num deles.
  • O endpoint de conta aberta (RN14) resolve pela garantia, como _get_billing já faz (billing_service.py:780-795): a garantia primeiro, a cobrança dentro dela. Garantia de outro tenant ⇒ 404.
  • FKs recebidas no corpo, e onde cada uma é resolvida. Esta spec acrescenta uma: o entity_id do upload de comprovante, que é um billing_id. Ele é resolvido no ramo explícito de billing que DocumentService._validate_entity (apps/api/app/services/document_service.py:749-755) ganha antes do caminho genérico, chamando BillingRepository.get_in_scope(billing_id, current_user) — a consulta que junta Guarantee e aplica tenant e imobiliária no mesmo where (RN20). Não passa por resolve_in_scope nem por _ENTITY_REPOS, e a RN20 explica por quê: nenhum dos dois consegue aplicar o escopo de imobiliária a uma entidade que não tem partner_agency_id próprio. ID de outro tenant ou de outra imobiliária ⇒ 404, e nada é gravado. É a RN5 da spec 0034.
  • payment_channel (RN15) não é FK e não decide poder — é um enum fechado, validado pelo Pydantic; valor fora do enum é 422 do próprio schema.
  • Registro de outro tenant responde 404, nunca 403, em todos os endpoints desta spec. O 403 fica reservado ao que o usuário não pode fazer no próprio tenant: o ADMIN_GLOBAL tentando exportar ou pagar, e o perfil sem billings:update tentando registrar pagamento.

10.4 Isolamento por imobiliária

  • Nada muda no que o AGENCY_USER alcança, e os campos novos não ampliam o que ele vê: tipo, parcela e canal descrevem a cobrança da própria garantia dele, que ele já lia. BillingAgencyResponse (apps/api/app/schemas/billing.py:456-471) ganha os mesmos campos que BillingResponse, menos os que revelam relação com terceiros — e não há nenhum neste conjunto.
  • collects_monthly é omitido para ele (10.1), e é a única omissão nova por perfil: os três parâmetros de encargo não são mascarados, por decisão da RN10a.
  • O comprovante herda o escopo do dono, por junção. Billing não tem partner_agency_id; o ramo novo de DocumentRepository._owned_by_agency (apps/api/app/repositories/document_repo.py:17-53) alcança Guarantee.partner_agency_id pela garantia da cobrança — o mesmo vínculo que list_cross_guarantee já usa (billing_repo.py:214). Vale para os dois caminhos que a imobiliária alcança, como manda a RN13 da 0034: metadado na listagem e conteúdo no download (document_service.py:391,468,531,537,545,588). O terceiro caminho daquela RN, a contagem no resumo de armazenamento, não se aplica aqui: a rota exige require_system_role("ADMIN_TENANT") (apps/api/app/api/v1/routers/documents.py:122), então nenhuma imobiliária a alcança e o agency_scope de document_service.py:634 é código inalcançável por ela. O comprovante entra na contagem do tenant, que é o que aquele endpoint mede.
  • O vínculo nunca vem do corpo. O upload recebe entity_type=billing e um entity_id; a imobiliária a que aquilo pertence é derivada da garantia, nunca aceita como campo. É a RN12 da 0034.
  • O usuário da garantidora continua vendo tudo, inclusive a carteira própria (RN14 da 0034) — nenhum filtro desta spec o restringe.
  • A fila de atrasados e o resumo não existem para a imobiliária, porque dependem de billings:list_all, que ela não tem. O rótulo AWAITING_AGENCY (RN24) aparece para ela no detalhe da própria garantia, e isso é desejado: é a parcela que ela mesma recolheu e ainda não repassou.

10.5 Plataforma × cliente (SaaS admin)

  • Quando quem chama é ADMIN_GLOBAL, tenant_scope devolve None (apps/api/app/services/scoping.py:42-48) e o BaseRepository não filtra nada (apps/api/app/repositories/base.py:56-57). Consequência, endpoint a endpoint desta spec:
  • GET /billings e GET /billings/{id} — devolvem todos os tenants. É continuidade deliberada da spec 0048, não decisão nova, e o motivo está registrado em rbac_seed.py:224-225. O detalhe por ID fica junto da lista e não com os agregados, apesar de esta spec aumentar o corpo dele: ele devolve um registro por chamada, não é extração em massa, e é dele que depende o redirecionamento da fila de trabalho da Central (/billings/{id} → a garantia), que existe desde a 0048.
  • GET /billings/summary e GET /billings/export — não alcança nenhum dos dois (RN26a): 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS. É onde esta spec para, deliberadamente, o que a 0048 não tinha diante de si — somar a carteira de cada cliente num punhado de números, e levá-la num arquivo.
  • GET .../charge e POST .../pay — os dois recusam antes de resolver, com 403: as duas rotas exigem billings:update, subtraída dele em rbac_seed.py:241, e a guarda _garantir_que_nao_e_o_saas_admin (billing_service.py:102-121) fecha o pay por desenho, com PLATFORM_ADMIN_CANNOT_SETTLE_BILLING. Ainda que a permissão lhe fosse concedida por engano, _get_billing passa current_user.tenant_id cru (billing_service.py:790-792), que é None para ele, e nenhuma cobrança casa — 404. São duas barreiras, e a primeira é a que responde.
  • POST .../billings (a avulsa) — já recusado hoje: _garantir_que_nao_e_o_saas_admin guarda também o create (apps/api/app/services/billing_service.py:102).
  • Upload, edição, substituição e remoção de comprovante — recusados pela guarda nova da RN20a, nos quatro caminhos.
  • Nenhuma tela do SaaS admin passa a mostrar dado de cliente. /app/admin/* não ganha nada nesta spec: não há cobrança, competência, resumo nem parceira lá. As três telas tocadas — Cobranças, Parâmetros e Imobiliárias — são da garantidora.
  • Nenhuma tela da garantidora aceita tenant_id de fora. Os filtros novos são fee_type, channel, overdue_only e a competência; nenhum deles nomeia tenant.
  • O que muda no que algum perfil passa a ver, para além de endpoint: o item Cobranças do menu ganha contagem de atrasados, e o item continua oculto para AGENCY_USER (apps/web/src/components/navigation/nav-groups.tsx:195-204). A contagem vem de GET /billings com overdue_only (RN25), que exige billings:list_all — então quem não pode ver a lista não recebe nem o número, e o badge não vira canal lateral de informação sobre a carteira.

E o ADMIN_GLOBAL, que tem billings:list_all, não recebe o badge. Ele alcança a casca da garantidora ao seguir o redirecionamento /billings/{id} da Central, e ali a contagem viria somada sobre todas as garantidoras — o mesmo número agregado que a RN26a lhe recusa, entregue de graça no menu. A contagem é pedida só quando quem olha tem tenant; sem tenant, o badge simplesmente não existe. É a mesma fronteira da RN26a, aplicada à superfície em vez de ao endpoint.

11. Impacto por app

apps/api

Modelos

Arquivo O que muda
app/models/billing.py enums BillingFeeType e BillingChannel; colunas de classificação fee_type, channel, setup_amount, guarantee_amount, installment_number, installment_total; colunas de baixa paid_on, payment_channel, setup_paid, guarantee_paid, misc_paid, charge_paid, discount_amount; reference_month vira nullable=False
app/models/tenant_settings.py enum CollectionMethod; colunas collection_method, interest_percent_month, late_fee_grace_days
app/models/partner_agency.py coluna collects_monthly
app/models/document.py quinta FK billing_id; ramos em entity_type e entity_id

Migrations — cinco revisões encadeadas a partir de e6f3c8d2b1a5, que é a cabeça atual; a tabela delas está em 12.9. Cada uma no molde de alembic/versions/f6h8j0l2n4p6_billing_origin_em_billings.py: docstring em português dizendo o que muda, por quê, o que acontece com as linhas existentes e o que o downgrade perde; sa.Enum declarado fora das funções; .create(op.get_bind(), checkfirst=True) antes do add_column e .drop(..., checkfirst=True) depois do drop_column; server_default em toda coluna NOT NULL. Detalhe da mais cara:

A classificação das cobranças já existentes não pode sair de server_default, porque o valor certo depende da linha. O passo de dados percorre as cobranças origin=CALENDAR por garantia: a de menor due_date é a entrada e recebe SETUP_AND_GUARANTEE (ou SETUP, se guarantees.charge_first_month_upfront for falso), com setup_amount = guarantees.initial_fee e guarantee_amount = guarantees.first_month_upfront; as demais recebem GUARANTEE com guarantee_amount = amount. origin=MANUAL recebe MISC. O channel de toda linha existente sai de GUARANTOR_BOLETO, porque collection_method nasce em BOLETO e nenhuma parceira nasce recolhendo. installment_number/installment_total são preenchidos pela ordem de due_date dentro da garantia. Não há carteira em produção (CLAUDE.md, "Estágio do produto"), então o alcance real disto é o dev-remote.

Endpoints — a permissão de cada um é a da seção 10.2, e o contrato, a seção 12.

  • GET /api/v1/guarantees/{gid}/billings — a resposta ganha os campos novos, inclusive em BillingAgencyResponse (RN13, seção 10.4)
  • GET /api/v1/billings — ganha fee_type, channel, situation e overdue_only; perde o filtro status e o sentinela sem-competencia (RN5, RN24a); a resposta ganha os campos novos e os dois projetados (RN13)
  • GET /api/v1/billings/{id} — a resposta ganha os mesmos campos
  • GET /api/v1/billings/summary — novo
  • GET /api/v1/billings/export — novo
  • GET /api/v1/guarantees/{gid}/billings/{bid}/charge — novo
  • POST /api/v1/guarantees/{gid}/billings/{bid}/pay — corpo maior (RN15)
  • POST /api/v1/documents — aceita entity_type=billing
  • GET/PATCH /api/v1/settings — três campos
  • POST /api/v1/onboarding/partner-agencies e PATCH /api/v1/partner-agencies/{id} — um campo

/summary e /export são declaradas antes de /{billing_id} no billings_router, pelo mesmo motivo que /reference-months já é (apps/api/app/api/v1/routers/billing.py:247-248): depois dela, o path variável as engoliria e summary viraria um UUID inválido.

Routers novos, se houver, precisam de route_class=CommitRoute — tests/test_transaction_boundary.py reprova rota sem ela. Aqui não há router novo: o resumo e a exportação entram em billings_router (apps/api/app/api/v1/routers/billing.py:30) e a conta aberta no router de /guarantees (:25) — os dois já a têm.

Regras no service

  • app/services/billing_service.py — create grava a classificação da avulsa (RN7); pay cresce (RN15–RN19, RN22); list_all ganha os filtros novos e os campos projetados (RN13); nasce summary (RN26–RN27), export (RN29) e charge (RN14). Toda consulta continua passando por BillingRepository, com tenant_scope e agency_scope — nenhum select novo no service.
  • app/services/guarantee_service.py — _gerar_calendario_de_cobranca (:705-747) preenche os campos novos (RN6). Quantidade, datas e valores não mudam.
  • app/domain/encargo.py — arquivo novo: a função pura do encargo (RN12) e os parâmetros da garantidora soltos das TenantSettings, que é o que permite a leitura projetar linha a linha com os parâmetros do dono de cada uma (RN13). No mesmo lugar de periodo.py e guarantee_status.py, e pelo mesmo motivo: é aritmética do negócio, e mais de um serviço a consome.
  • app/domain/distribuicao_de_baixa.py — arquivo novo: as três condições da RN16, o teto da RN19 e o abatido. Separado do serviço porque a ordem em que as recusas são verificadas é decisão de desenho — mais de uma pode valer na mesma chamada — e precisa de um lugar onde a razão de cada uma esteja escrita.
  • app/domain/moeda.py — arquivo novo, e nasceu na Fase 5: brl(), o dinheiro em português. As recusas da distribuição viajam com a frase pronta (message) porque nenhum cliente deveria remontar texto a partir de max/min — e a frase encosta, no mesmo formulário, em valores que o apps/web formata com fmtBRL. R$ 1022.00 ao lado de R$ 1.022,00 lê-se como defeito. A implementação veio de services/credit.py, que passou a delegar: o segundo consumidor era a chance de virar a segunda cópia.
  • app/services/document_service.py — _VALID_ENTITY_TYPES deixa de derivar de _ENTITY_REPOS e ganha billing; o kwargs do upload e as duas guardas de escrita também; o ramo explícito de resolução entra em _validate_entity, antes do caminho genérico (RN20, RN20a); e o upload, no caso billing, remove logicamente o comprovante anterior daquela cobrança antes de gravar o novo (RN20b) — é o único ponto do serviço que substitui em vez de acrescentar, e por isso está dito aqui.
  • app/repositories/document_repo.py — col_map e _owned_by_agency ganham o ramo que alcança Guarantee.partner_agency_id pela cobrança (RN20).
  • app/repositories/billing_repo.py — filtros novos, get_in_scope (RN20), a consulta de agregação do resumo e a da fila de atrasados. receipt_document_id sai por outerjoin — em _base_cross_guarantee (apps/api/app/repositories/billing_repo.py:91-104) para a lista agregada, como renter_name e partner_agency_name já saem, e nas consultas por garantia (list_by_guarantee, :17-40, e get_by_guarantee_and_id, :62-77), que é por onde a imobiliária o alcança. Nunca por consulta por linha.
  • app/services/operations_center_service.py — _monthly_financial troca o recorte (RN28); e os três pontos que emitem action_url="/billings?status=OVERDUE" (:158,174,386) passam a emitir /billings?aba=atrasados, que é o destino que a Parte II decidiu. Sem isso o link continuaria "funcionando" — o FastAPI ignora query param desconhecido em silêncio, e o front abriria a competência corrente sem filtro nenhum, que é o recorte errado e mudo.
  • app/db/rbac_seed.py — declara billings:export em _PERMISSIONS e a concede a ADMIN_TENANT, ANALYST, OPERATOR e VIEWER em _ROLE_PERMISSIONS; não a ADMIN_GLOBAL nem a AGENCY_USER (seção 10.2). A subtração do ADMIN_GLOBAL ganha comentário no mesmo formato dos três que já estão lá (:206-219).
  • app/worker.py — segundo job cron (RN23), acrescentado à lista de cron_jobs (:102). Não entra em functions — essa lista é só para job enfileirado.
  • app/schemas/billing.py — os campos novos na Response e os da baixa no BillingPayRequest. BillingCreate não os recebe: fee_type, channel e a parcela são derivados pelo service (RN7), e o create desempacota o corpo direto no modelo (apps/api/app/services/billing_service.py:145) — bastaria o campo existir no schema para o corpo gravá-lo, e quem lançasse uma avulsa poderia escolher AGENCY_PIX, tirando aquela cobrança da rotina de atraso (RN23) e do a_receber_garantidora (RN27). reference_month continua opcional ali, como hoje — o que muda é que, omitido, o service o deriva do vencimento em vez de deixá-lo nulo (RN5).
  • app/schemas/tenant_settings.py e partner_agency.py — os campos novos nas três classes de cada (Create/Update/Response), incluindo o json_schema_extra.
  • app/main.py — o TAGS_METADATA da tag Cobranças passa a mencionar resumo e exportação; e a description do parâmetro reference_month em app/api/v1/routers/billing.py:48,60 perde a menção ao sentinela revogado (RN5).
  • pyproject.toml — dependência nova para gerar .xlsx. Não há nenhuma hoje, nem no front; a escolha e a justificativa ficam na seção 12.

Testes (apps/api/tests/) — o molde é test_billing_automation.py, que importa os helpers de mundo de test_analise_completa.py e monta garantia + calendário pela API.

  • Caminho feliz: a geração classifica os quatro campos nos dois modos de charge_first_month_upfront; a avulsa nasce MISC; o encargo com e sem carência, com juros nulo, no dia exato do fim da carência e no seguinte; a distribuição que fecha; o resumo somando a entrada decomposta e contando a parcela uma vez; a identidade dos indicadores fechando numa competência com desconto e com abono; a rotina marcando só o canal da garantidora.
  • Acesso negado, um por linha da matriz 10.1: ADMIN_GLOBAL recebe 403 em /billings/export e continua recebendo 403 no pay; AGENCY_USER recebe 403 em /billings/summary e em /billings/export, e não vê collects_monthly na resposta da própria imobiliária; ANALYST recebe 403 ao pagar e 200 ao exportar; cobrança de outro tenant responde 404 no charge e no upload de comprovante; comprovante de garantia de outra imobiliária responde 404 no metadado, no download e na contagem.
  • Regressão automática: o teste de matriz da RN15/RN16 da spec 0034 descobre as rotas a partir de app.routes, então os endpoints novos entram cobertos sem lista à mão.

apps/web

Páginas e componentes

  • src/pages/billings/index.tsx — a fita de abas, os cinco indicadores, o resumo, os filtros novos, as nove colunas recompostas, a fila de atrasados; some a leitura de ?status= na montagem (:99), entra a leitura da aba na URL
  • src/components/shared/ — PageTabs, componente novo e compartilhado (Parte II, 7.A): rótulo, contagem opcional, fita rolável em 375px, estado na URL. Nasce aqui porque a spec do acerto acrescenta a terceira aba
  • src/pages/guarantees/components/BillingList.tsx — as três colunas novas e a fusão Competência→Vencimento, ficando em oito; o cartão mobile ganha a linha de contexto
  • src/pages/guarantees/components/BillingPayDialog.tsx — os quatro blocos, a conta aberta recalculando por data, a distribuição com trava, o abono com motivo, a observação e o comprovante
  • src/pages/settings/index.tsx — três campos: juros no painel Taxas, carência e meio de cobrança em Cobrança e sinistro
  • src/pages/agencies/form.tsx, index.tsx, show.tsx, api.ts — o campo collects_monthly, a coluna Mensalidades e o selo
  • src/pages/billings/api.ts — resumo, exportação e conta aberta
  • src/providers/api-fetch.ts — apiReq não serve para baixar arquivo: ele força Content-Type: application/json e JSON.stringify. A exportação usa o caminho do download de documento (src/shared/api/documents.ts:163-187: fetch com Authorization manual, res.blob(), URL.createObjectURL, <a download>, revokeObjectURL)
  • src/contexts/nav-badges/index.tsx — campo novo, overdueBillings. Não se reaproveita overdueGuarantees: ele pinta o item Garantias (src/components/navigation/nav-groups.tsx:191-193, e o próprio contexto o documenta assim em :6), e alimentá-lo com contagem de cobrança acenderia o badge na tela errada. Ele está cravado em 0 hoje (apps/web/src/pages/operations-center/index.tsx:96, com o comentário dizendo que falta contagem na API) e continua como está — esta spec não o resolve
  • src/components/navigation/nav-groups.tsx — o item Cobranças (:195-204), que hoje não tem badge nenhum, ganha badgeCount: badges.overdueBillings e badgeColor: "error"
  • src/pages/operations-center/index.tsx e src/pages/billings/index.tsx — passam a escrever overdueBillings pela contagem da RN25, e só quem tem tenant a pede: sem tenant, o badge não existe (seção 10.5); a tela de Cobranças o atualiza ao entrar e depois de cada ação (Parte II, 7.A). Hoje nenhuma tela além do Painel escreve nesse contexto
  • src/shared/utils/dateUtils.ts — duas funções novas e compartilhadas, hojeLocalISO() e formatarDataISO(), nascidas de defeitos de fuso desta fase: "hoje" por toISOString() (UTC, que às 21h de Brasília já virou amanhã) e new Date("AAAA-MM-DD") (meia-noite UTC, que imprime o dia anterior). Ficam aqui porque já eram três consumidores — e porque a primeira já estava resolvida em SignatureDialog.tsx, dois arquivos ao lado, sem que ninguém achasse
  • vite.config.ts — TZ=America/Sao_Paulo no test.env. A suíte rodava em UTC, onde meia-noite UTC e meia-noite local coincidem: nenhum dos defeitos de fuso acima ficaria vermelho, e o red-green das duas funções mediria nada. É o fuso em que o produto roda — o mesmo hoje_local() que a API usa para recusar data futura

data-testid novos — billings-tabs, billings-tab-mes, billings-tab-atrasados, billings-tab-atrasados-badge, billings-kpi-{previsto,recebido,a-receber,a-repassar,em-atraso}, billings-summary, billings-summary-row-{feeType}-{channel}, billings-summary-total, billings-summary-toggle (mobile), billings-tipo-filter, billings-canal-filter, billings-situacao-filter (o billings-status-filter de hoje, apps/web/src/pages/billings/index.tsx:269,301, é renomeado junto do rótulo — e o spec e2e da 0048 que o usa acompanha), billings-export, billing-receipt-{id}, billing-pay-date-input, billing-pay-channel-select, billing-pay-amount-input, billing-pay-alloc-{setup,guarantee,misc,charge}-input, billing-pay-waive-checkbox, billing-pay-waive-reason-input, billing-pay-notes-input, billing-pay-receipt-input, billing-pay-submit, settings-interest-input, settings-late-fee-grace-input, settings-collection-method-select, agency-collects-monthly-select.

Sider: não é módulo novo — /billings já está em App.tsx e em nav-groups.tsx. O que muda é o item ganhar badgeCount, que ele hoje não tem.

Menu e rota por permissão: ExigeAcesso permissao="billings:list_all" continua governando /billings (App.tsx:384), e o item segue oculto para AGENCY_USER. Quem digitar a URL direto continua recebendo a página de acesso negado — esconder o menu não protege rota, e aqui os dois portões já existem.

Responsividade & PWA: a Parte II descreve os dois viewports. O PWA entra por um ponto: o download do .xlsx na versão instalada vai para o destino do sistema e a tela não o controla (Parte II, 7.2) — por isso o aviso de conclusão nomeia o arquivo. Nenhum asset novo, nenhuma tela offline.

Suíte unitária (spec 0047, *.test.tsx ao lado do arquivo): o cálculo do rótulo de parcela e de tipo, a trava da distribuição, o estado "sem encargo parametrizado", a troca de aba preservando filtros comuns, e o resumo recolhendo no mobile. Layout medido — a fita rolável, o teclado sobre o campo — continua no e2e, porque o jsdom não tem layout.

apps/e2e

Specs — em tests/e2e/billings.spec.ts, que já existe:

  • a cobrança nasce classificada nos dois modos de charge_first_month_upfront, e a lista mostra tipo, parcela e canal;
  • o resumo fecha com a lista, e a entrada aparece decomposta nas duas linhas;
  • registrar pagamento com encargo, conferindo que mudar a data muda o total;
  • abono exigindo motivo, e a distribuição travando quando não fecha;
  • anexar comprovante e reabri-lo pela linha;
  • a aba Atrasados trazendo parcela de competência anterior e o badge batendo com ela;
  • exportar a competência e conferir que o arquivo chega;
  • negativo: a imobiliária não alcança /billings (já existe, :335-341) e não vê o campo de recolhimento no próprio cadastro;
  • 375px: o painel de pagamento com o total preso no topo e a confirmação acima do teclado.

Seeds: nenhum arquivo novo em tests/fixtures/seeds/. A spec 0048 registrou por quê (tests/e2e/billings.spec.ts:26-33): uma garantia é objeto composto — análise aprovada → emissão → assinatura —, não registro plano, e não cabe no formato SeedFile<T>. O mundo continua sendo montado pela API, reusando garantiaComCalendarioCompartilhada(). O que muda é que o helper passa a aceitar o meio de cobrança do tenant e o collects_monthly da parceira, para o spec do canal ter os dois lados.

Se algum app não é afetado, escrever "sem impacto" explicitamente — vale mais que a seção ausente.

12. Contrato de API

Erro comum a todos, e por isso dito uma vez: registro de outro tenant ou de outra imobiliária responde 404 NOT_FOUND, nunca 403 (RN4 da spec 0034); 403 fica para o que o usuário não pode fazer no próprio tenant.

12.1 GET /api/v1/billings — alterado

GET /api/v1/billings?reference_month=2026-07&fee_type=SETUP&channel=GUARANTOR_BOLETO
                    &situation=OVERDUE&overdue_only=false&q=&page=1&page_size=25
# `situation` substitui o filtro `status` de hoje (RN24a); `reference_month` valida formato (RN5)
Authorization: Bearer <token>

Permissão billings:list_all. Sai o valor sem-competencia de reference_month (RN5). overdue_only=true ignora reference_month e ordena por due_date crescente (RN25), e convive com situation — os dois se aplicam, então overdue_only=true&situation=OVERDUE devolve a fatia já marcada da fila, e situation=PAID com overdue_only=true devolve vazio, que é a resposta correta e não um erro.

{
  "items": [
    {
      "id": "uuid", "guarantee_id": "uuid", "guarantee_number": "G202607-00042",
      "renter_name": "Inquilino Exemplo 21", "partner_agency_name": "Solar Imóveis",
      "reference_month": "2026-07", "due_date": "2026-07-10",
      "fee_type": "SETUP_AND_GUARANTEE", "channel": "GUARANTOR_BOLETO",
      "installment_number": 1, "installment_total": 12,
      "amount": "714.04", "setup_amount": "300.00", "guarantee_amount": "414.04",
      "amount_paid": null, "discount_amount": null, "paid_at": null,
      "paid_on": null, "payment_channel": null, "setup_paid": null,
      "guarantee_paid": null, "misc_paid": null, "charge_paid": null,
      "status": "OVERDUE", "situation": "OVERDUE",
      "late_fee_projected": "14.28", "interest_projected": "12.38",
      "receipt_document_id": null, "origin": "CALENDAR", "deleted_at": null
    }
  ],
  "total": 44, "page": 1, "page_size": 25
}

situation, late_fee_projected e interest_projected são derivados (RN13, RN24), nunca persistidos. receipt_document_id é o comprovante, quando houver (RN20).

12.2 GET /api/v1/billings/summary — novo

Mesmos filtros da lista. Permissão billings:list_all e ter tenant — ADMIN_GLOBAL recebe 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS (RN26a).

{
  "reference_month": "2026-07",
  "indicators": {
    "previsto": "11286.74", "recebido": "360.00", "abatido": "0.00",
    "a_receber_garantidora": "4297.25", "a_repassar_imobiliaria": "6629.49",
    "em_atraso_no_mes": { "valor": "1217.50", "parcelas": 3, "encargo_corrido": "26.66" }
  },
  "rows": [
    { "fee_type": "SETUP", "channel": "GUARANTOR_BOLETO", "cobrado": "2500.00",
      "pago": "250.00", "abatido": "0.00", "a_receber": "2250.00",
      "encargo_recebido": "0.00", "parcelas": 9 }
  ],
  "total": { "cobrado": "11286.74", "pago": "360.00", "abatido": "0.00",
             "a_receber": "10926.74", "encargo_recebido": "7.32", "parcelas": 44 }
}

indicators satisfaz previsto = recebido + abatido + a_receber_garantidora + a_repassar_imobiliaria (RN27). em_atraso_no_mes fica fora dessa soma, por atravessar as duas últimas.

12.3 GET /api/v1/billings/export — novo

Mesmos filtros. Permissão billings:export, e ter tenant (RN26a). Exige reference_month ou overdue_only=true; sem nenhum dos dois ⇒ 422 REFERENCE_MONTH_REQUIRED, pela mesma razão do resumo — um arquivo sem recorte seria a carteira inteira de todos os tempos.

200 OK
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="cobrancas-2026-07.xlsx"

Duas abas na competência (resumo + parcelas), uma na fila de atrasados, com cobrancas-atrasadas-2026-09-20.xlsx. Erros: 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS para o ADMIN_GLOBAL e 403 por falta de permissão para o AGENCY_USER (seção 10.2).

A biblioteca é openpyxl, acrescentada a apps/api/pyproject.toml — hoje sem nenhuma biblioteca de planilha (:6-23). Escolhida por ser Python puro, sem compilação nativa, e por escrever .xlsx com formatação de célula, que é tudo de que se precisa. pandas traria um universo para formatar uma tabela; xlsxwriter resolveria igual, e a escolha entre os dois é de gosto — fica registrada para não ser reaberta na implementação. A dependência nova obriga rebuild das imagens api e worker: as duas saem do mesmo Dockerfile, então o openpyxl entra na do worker mesmo sem ele o usar, e o container precisa ser recriado junto (Fechamento, seção 14).

12.4 GET /api/v1/guarantees/{gid}/billings/{bid}/charge — novo

GET /api/v1/guarantees/{gid}/billings/{bid}/charge?on=2026-07-20

Permissão billings:update, não billings:read. O motivo é o ato, não o sigilo: a conta aberta existe para montar a baixa, e quem não dá baixa não tem o que fazer com ela. A imobiliária tem billings:read (apps/api/app/db/rbac_seed.py:422) e por isso alcançaria o endpoint; ela fica de fora por não registrar pagamento, não por causa do grace_days no corpo — que ela pode ver de qualquer modo (RN10a). on ausente vale hoje.

{
  "on": "2026-07-20", "days_late": 52, "grace_days": 5,
  "setup_amount": "300.00", "guarantee_amount": "414.04",
  "late_fee": "14.28", "interest": "12.38", "total_due": "740.70"
}

Erros: 422 CHARGE_DATE_IN_FUTURE (RN14); 422 BILLING_ALREADY_SETTLED sobre cobrança PAID ou CANCELLED — não há conta aberta a fazer sobre o que está fechado, e é o mesmo código que o pay já usa nesse caso; 404 pela garantia.

12.5 POST /api/v1/guarantees/{gid}/billings/{bid}/pay — alterado

POST /api/v1/guarantees/{gid}/billings/{bid}/pay
Authorization: Bearer <token>

{
  "paid_on": "2026-07-20",
  "payment_channel": "GUARANTOR_PIX",
  "amount_paid": "714.04",
  "setup_paid": "300.00",
  "guarantee_paid": "414.04",
  "misc_paid": "0.00",
  "charge_paid": "0.00",
  "waive_late_fee": true,
  "waive_reason": "Atraso por falha no envio do boleto",
  "notes": "Comprovante enviado por WhatsApp"
}

Permissão billings:update. Erros previstos:

Código Quando
422 PAYMENT_ALLOCATION_MISMATCH a distribuição não fecha, ou há abono com charge_paid diferente de zero (RN16, RN18)
422 PAYMENT_DATE_IN_FUTURE paid_on depois de hoje (RN17)
422 WAIVER_REASON_REQUIRED abono sem motivo (RN18)
422 PAYMENT_EXCEEDS_DUE recebido acima do devido na data (RN19)
422 BILLING_ALREADY_SETTLED já existente, cobrança fora de PENDING/OVERDUE
403 PLATFORM_ADMIN_CANNOT_SETTLE_BILLING já existente, ADMIN_GLOBAL

12.6 POST /api/v1/documents — alterado

entity_type passa a aceitar billing (RN20). Permissões documents:create e billings:update (RN20a). Erros novos: 403 PLATFORM_ADMIN_CANNOT_ATTACH_BILLING_RECEIPT, 403 AGENCY_CANNOT_WRITE_BILLING_DOCUMENT e 403 BILLING_RECEIPT_REQUIRES_UPDATE — este último é a recusa por falta de billings:update, que a RN20a exige mas não nomeava: o ForbiddenError cru sai como FORBIDDEN, e a tela precisa distinguir "seu perfil não dá baixa" de "a plataforma não movimenta dinheiro de cliente". Vale nos quatro caminhos de escrita, incluindo PUT /documents/{id}/content. Erros já existentes: 422 INVALID_ENTITY_TYPE, INVALID_FILE_TYPE, FILE_TOO_LARGE, EMPTY_FILE, CORRUPT_FILE, ENTITY_DOCUMENT_LIMIT_EXCEEDED; 404 para cobrança fora do escopo.

12.7 GET / PATCH /api/v1/settings — alterados

Os campos novos, cada um nas classes Create, Update e Response:

Campo Tipo Obrigatório Default
collection_method "BOLETO" \| "PIX" sim no Create BOLETO (server_default)
interest_percent_month Decimal, 0 a 100 não null
late_fee_grace_days int, 0 a 180 não null

PATCH exige require_system_role("ADMIN_TENANT"), como hoje. Os três saem na resposta do GET para todos os perfis que a alcançam, inclusive o AGENCY_USER — sem máscara, por decisão da RN10a.

12.8 POST /onboarding/partner-agencies e PATCH /partner-agencies/{id} — alterados

Campo Tipo Obrigatório Default
collects_monthly bool não false (server_default)

Omitido da resposta para quem tem partner_agency_id (seção 10.1), e recusado no corpo para o ADMIN_GLOBAL com 403 PLATFORM_ADMIN_CANNOT_SET_COLLECTION (RN11a).

12.9 Migrations

Cinco, encadeadas a partir de e6f3c8d2b1a5 — a cabeça atual, confirmada no banco do dev-remote.

Revisão O que faz downgrade
classificacao_de_cobranca enums billing_fee_type e billing_channel; as seis colunas de classificação em billings; reference_month vira NOT NULL; o passo de dados da seção 11 derruba as colunas e os dois tipos, e devolve reference_month a anulável — perde-se a classificação, que é derivável de novo do calendário; cobrança nenhuma some
baixa_detalhada paid_on, payment_channel, setup_paid, guarantee_paid, misc_paid, charge_paid e discount_amount em billings, todas anuláveis derruba as sete — perde-se a decomposição das baixas já registradas, e o resumo por tipo passa a contar zero na coluna pago das competências afetadas; amount_paid sobrevive, então nenhum valor recebido se perde
meio_de_cobranca_e_encargo enum collection_method e três colunas em tenant_settings derruba as três e o tipo — perde-se a parametrização de encargo, e o cálculo volta ao que é hoje
imobiliaria_recolhe_mensalidades collects_monthly em partner_agencies derruba a coluna — toda parceira volta a "não recolhe", e o canal de parcela futura volta ao meio da garantidora
comprovante_na_cobranca billing_id em documents, FK ondelete=SET NULL, indexada derruba a coluna — o comprovante perde o dono e fica órfão; por isso o downgrade apaga antes os documentos que só tinham essa FK, em vez de deixar registro sem entidade

O downgrade da última é o único que perde dado, e é o único que precisa de decisão escrita — por isso ela está aqui e não na implementação.

13. Critérios de aceite

Cada item precisa ser verificável por alguém que não escreveu o código. Cobrir também o que a Parte II prometeu — jornada, mobile e estados.

Se a feature toca autorização, exige-se pelo menos um CA negativo por papel afetado: "o AGENCY_USER da Imobiliária B recebe 404 ao abrir o imóvel da A" é critério; "respeita as permissões" não é.

Classificação e geração

  • [ ] CA1. Dada uma garantidora com collection_method = BOLETO e charge_first_month_upfront ligado, quando uma garantia de 12 meses é assinada, então nascem 12 cobranças: a primeira com fee_type = SETUP_AND_GUARANTEE, setup_amount = initial_fee, guarantee_amount = first_month_upfront, installment_number = 1, installment_total = 12; e 11 com fee_type = GUARANTEE, installment_number de 2 a 12. A última lê 12/12 (RN1, RN2, RN4, RN6).
  • [ ] CA2. Dada a mesma garantia com charge_first_month_upfront desligado, então nascem 13 cobranças: a de setup com fee_type = SETUP, guarantee_amount nulo e parcela nula; e 12 mensais numeradas de 1/12 a 12/12. Nenhuma parcela repete número (RN2, RN4, RN6).
  • [ ] CA3. Dada uma parceira com collects_monthly verdadeiro, quando a garantia dela é assinada, então a entrada nasce GUARANTOR_BOLETO e todas as mensais nascem AGENCY_PIX; com collects_monthly falso, todas nascem GUARANTOR_BOLETO (RN3, RN9, RN11).
  • [ ] CA4. Dada uma garantidora com collection_method = PIX, então a entrada nasce GUARANTOR_PIX (RN8, RN9).
  • [ ] CA5. Dada uma cobrança lançada à mão sem reference_month no corpo, então ela nasce fee_type = MISC, canal do meio da garantidora, parcela nula e reference_month igual ao mês do due_date informado (RN7).
  • [ ] CA6. Depois da migration, nenhuma cobrança tem reference_month nulo, e o filtro de competência não aceita mais o valor sem-competencia — enviá-lo responde 422 em vez de listar (RN5).
  • [ ] CA7. Para toda cobrança com fee_type diferente de MISC, coalesce(setup_amount,0) + coalesce(guarantee_amount,0) é igual a amount — inclusive nas cobranças que a migration classificou (RN2).

Encargo

  • [x] CA8. Com multa 2%, juros 1% ao mês e carência 5 dias, uma parcela de R$ 1.000 vencida há 5 dias tem encargo zero; há 6 dias tem multa R$ 20,00 e juros R$ 2,00 (1000 × 1% × 6/30); há 0 dias, zero (RN12).
  • [x] CA9. Com juros não configurado e multa 2%, a mesma parcela vencida há 6 dias tem multa R$ 20,00 e juros R$ 0,00 — a ausência de um parâmetro não zera o outro (RN10, RN12).
  • [x] CA10. GET /billings devolve late_fee_projected e interest_projected calculados para hoje em toda parcela vencida, e zero nas demais (RN13).
  • [x] CA11. GET .../charge?on= devolve o mesmo que a CA8 para a data pedida, e responde 422 CHARGE_DATE_IN_FUTURE para data futura (RN14).

Pagamento

  • [x] CA12. Um pagamento cuja distribuição (`setup_paid + guarantee_paid + misc_paid
  • charge_paid) não someamount_paidresponde422 PAYMENT_ALLOCATION_MISMATCH` e não grava (RN15, RN16).
  • [x] CA13. paid_on no futuro responde 422 PAYMENT_DATE_IN_FUTURE; paid_on anterior ao vencimento é aceito e produz encargo zero (RN17).
  • [x] CA14. waive_late_fee verdadeiro sem motivo responde 422 WAIVER_REASON_REQUIRED; com motivo e charge_paid diferente de zero, responde 422 PAYMENT_ALLOCATION_MISMATCH (RN18).
  • [x] CA15. Pagando R$ 900 de um principal de R$ 1.000, a cobrança fica PAID com discount_amount = 100.00; pagando R$ 1.200 do mesmo total devido, responde 422 PAYMENT_EXCEEDS_DUE com o teto no corpo (RN19).
  • [x] CA16. O registro do pagamento grava auditoria PAID cujo payload traz data, canal de entrada, a distribuição, o abono e o motivo (RN22).
  • [x] CA17. Um comprovante enviado com entity_type=billing fica acessível pelo receipt_document_id da cobrança, e falhar o anexo não desfaz o pagamento já gravado (RN20, RN21).

Atraso

  • [x] CA18. Executada a rotina diária, toda cobrança PENDING vencida de canal GUARANTOR_BOLETO ou GUARANTOR_PIX passa a OVERDUE, e nenhuma de canal AGENCY_PIX muda de status (RN23).
  • [x] CA19. Uma cobrança PENDING, vencida, de canal AGENCY_PIX tem situation = AWAITING_AGENCY; marcada à mão, passa a OVERDUE nos dois campos. O enum BillingStatus continua com os mesmos quatro valores (RN24).
  • [x] CA20. GET /billings?overdue_only=true devolve as vencidas e não pagas de todas as competências, ordenadas do mais vencido para o menos, e ignora reference_month quando ele vier junto (RN25).
  • [x] CA21. A rotina falhando para uma garantidora não impede as demais de serem processadas, e a falha aparece no log com o tenant_id (RN23).

Agregados e exportação

  • [x] CA22. Numa competência com uma entrada de R$ 714,04 (setup R$ 300 + fiança R$ 414,04), o resumo mostra R$ 300 na linha de SETUP e R$ 414,04 na de GUARANTEE, e a parcela é contada uma vez, na linha de SETUP; o total de parcelas é a soma das linhas (RN26).
  • [x] CA23. Numa competência com um desconto de R$ 100 e um abono de multa de R$ 20, previsto = recebido + abatido + a_receber + a_repassar fecha, abatido é R$ 100 — não R$ 120 — e o encargo abonado aparece só como encargo_recebido menor (RN19, RN27).
  • [x] CA24. Cobrança removida não entra no resumo nem no arquivo, mesmo com Mostrar removidos ligado (RN26, RN29).
  • [x] CA25. GET /billings/export na competência devolve .xlsx com duas abas, e na fila de atrasados com uma; os valores chegam como número, não texto (RN29).
  • [x] CA26. Depois da RN28, o previsto do mês corrente em /operations-center/financial-summary é igual ao previsto de /billings/summary da mesma competência (RN5, RN28).

Autorização — os negativos, um por papel afetado

  • [x] CA27. ADMIN_GLOBAL recebe 200 em GET /billings, com registros de mais de um tenant, e continua recebendo 403 em POST .../pay (seção 10.1, 10.5). Os agregados são a CA38.
  • [ ] CA28. AGENCY_USER recebe 403 em GET /billings/summary e em GET /billings/export; e GET /partner-agencies/{a própria} não traz collects_monthly no corpo (seção 10.1, 10.4).
  • [x] CA29. ANALYST, OPERATOR e VIEWER recebem 200 em /billings/summary e em /billings/export, e 403 em POST .../pay (seção 10.1, 10.2).
  • [x] CA30. ADMIN_TENANT da Garantidora A recebe 404 — não 403 — ao pedir GET .../billings/{id}/charge de uma cobrança da Garantidora B, e 404 ao anexar comprovante nela; nada é gravado (seção 10.3).
  • [x] CA31. AGENCY_USER da Imobiliária A recebe 404 ao baixar o comprovante de uma cobrança da Imobiliária B e ao ler o metadado dele na listagem de documentos. O resumo de armazenamento fica fora deste critério: a rota é de ADMIN_TENANT, e o comprovante entra na contagem do tenant como qualquer outro documento (seção 10.4).
  • [ ] CA32. Um usuário sem billings:list_all que digite /billings na URL recebe a página de acesso negado, e não recebe a contagem de atrasados por nenhum caminho (seção 10.5). Experiência — o que a Parte II prometeu

  • [ ] CA33. A tela abre na competência corrente, com as duas abas; a aba ativa vai para a URL e recarregar a preserva; trocar de aba mantém busca, Tipo, Canal e Mostrar removidos, e a Competência não oferece a opção "Todos".

  • [ ] CA34. Em 375px: a fita de abas rola sem barra visível; os indicadores são três; o resumo nasce recolhido e abre em blocos; a lista é cartão, com tipo, parcela e canal numa só linha de contexto; não há rolagem horizontal (scrollWidth - clientWidth == 0).
  • [ ] CA35. Em 375px, no painel de pagamento: o total devido permanece visível ao rolar com o teclado aberto, a distribuição está recolhida e abre sozinha quando a soma não fecha, e a confirmação fica acima do teclado.
  • [x] CA36. Com a garantidora não cobrando encargo nenhum — nenhum dos percentuais maior que zero —, a lista não mostra "+ multa" solto, o cartão Em atraso no mês diz que ela não cobra multa nem juros, e o painel de pagamento leva a Parâmetros, sem calcular encargo pela metade.

A condição foi reescrita na revisão final, e a versão anterior descrevia um estado inalcançável. Ela dizia "com juros e carência não configurados", que é o estado do dia seguinte de toda garantidora — mas late_fee_percent é não anulável, com default 2,00 (apps/api/app/models/tenant_settings.py:64-66), então nesse estado a multa de 2% existe e o encargo corre. O aviso não deveria aparecer ali, e não aparece: a RN12 é explícita em que parâmetro nulo zera a sua parcela do encargo e só a sua. O que a tela precisa dizer é o que de fato acontece quando nada é cobrado, e é isso que o critério passa a cobrar. Junto, o texto do aviso mudou de "não estão parametrizados" para "não cobra multa nem juros": a tela não distingue quem ainda não configurou de quem configurou zero, e zero é política, não lacuna. - [ ] CA37. Depois de registrar o pagamento da última parcela vencida, a contagem de atrasados no menu e na aba vai a zero sem recarregar a página, e o badge some.

Autorização — as guardas que não existem hoje

  • [x] CA38. ADMIN_GLOBAL recebe 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS em GET /billings/summary e em GET /billings/export, e continua recebendo 200 em GET /billings com registros de mais de um tenant (RN26a).
  • [x] CA39. ADMIN_GLOBAL anexando comprovante numa cobrança de qualquer tenant recebe 403 PLATFORM_ADMIN_CANNOT_ATTACH_BILLING_RECEIPT — e não um erro de integridade, que é o que o caminho de hoje produziria (RN20a).
  • [x] CA40. AGENCY_USER da imobiliária dona da garantia lê o comprovante da cobrança dela e recebe 403 AGENCY_CANNOT_WRITE_BILLING_DOCUMENT ao tentar anexar um (RN20a, seção 10.4).
  • [x] CA41. ANALYST e VIEWER recebem 403 ao anexar comprovante — neles o porteiro do router já basta, por não terem documents:create (apps/api/app/db/rbac_seed.py:329,379). O OPERATOR é o caso que a RN20a existe para cobrir: ele tem documents:create, documents:update e documents:delete (:361), e recebe 403 ao anexar, ao editar e ao remover comprovante, por não ter billings:update (:359).
  • [ ] CA42. ADMIN_GLOBAL enviando collects_monthly em PATCH /partner-agencies/{id} recebe 403 PLATFORM_ADMIN_CANNOT_SET_COLLECTION, e o valor gravado não muda (RN11a).
  • [x] CA43. AGENCY_USER da Imobiliária A que envie o id de uma cobrança da Imobiliária B no upload de comprovante recebe 404 — não 403 —, e nada é gravado; o BillingRepository.get_in_scope aplica os dois escopos na mesma consulta (RN20, seção 10.3).
  • [x] CA44. GET /billings?situation=AWAITING_AGENCY devolve exatamente as PENDING vencidas de canal AGENCY_PIX, e o filtro status não existe mais na lista agregada (RN24a).
  • [ ] CA45. GET /billings?reference_month=sem-competencia e ?reference_month=05/2026 respondem 422 INVALID_REFERENCE_MONTH (RN5).
  • [x] CA46. Numa página da lista agregada com cobranças de dois tenants com multas diferentes, cada linha traz o encargo projetado pelos parâmetros do próprio tenant (RN13).
  • [x] CA47. Registrada a baixa, as colunas paid_on, payment_channel, setup_paid, guarantee_paid e charge_paid refletem o corpo enviado, e paid_at guarda o instante do registro — lançar hoje um pagamento de ontem produz o encargo de ontem (RN15, RN12).
  • [x] CA48. Anexado um segundo comprovante na mesma cobrança, o primeiro é removido logicamente e receipt_document_id passa a apontar para o novo (RN20b).
  • [x] CA49. Numa competência com uma cobrança cancelada, o previsto do resumo e o da Central de Operações continuam iguais — a cancelada não entra em nenhum dos dois (RN26, RN28).
  • [x] CA50. GET /billings/summary sem reference_month responde 422 REFERENCE_MONTH_REQUIRED (RN27).
  • [x] CA51. AGENCY_USER recebe 403 em GET .../billings/{bid}/charge da própria garantia, por não ter billings:update — e não por sigilo: ela vê o encargo projetado na lista (RN13) e a carência em GET /settings (RN10a). O endpoint é do ato de dar baixa (RN14, seção 10.1).
  • [ ] CA52. AGENCY_USER enviando collects_monthly em PATCH /partner-agencies/{a própria} recebe 403 — PartnerAgencyService.update já exige ser ADMIN_GLOBAL ou ADMIN_TENANT (apps/api/app/services/partner_agency_service.py:118), e o campo continua omitido da resposta que ela lê (seção 10.1, 10.4).
  • [x] CA53. Uma parcela de R$ 1.000 com R$ 20 de encargo, paga com amount_paid = 1000 distribuído em guarantee_paid = 980 e charge_paid = 20, grava discount_amount = 20; e a competência fecha previsto = recebido + abatido + a_receber + a_repassar (RN19, RN27).
  • [ ] CA54. Uma cobrança avulsa de R$ 150 paga integralmente grava misc_paid = 150, entra em recebido e aparece na linha Avulsas do resumo com pago = 150 (RN15, RN26, RN27).
  • [ ] CA55. Registrada a baixa de uma cobrança vencida, o badge do item Cobranças do menu diminui — e o de Garantias não muda (seção 11).
  • [x] CA56. Uma entrada de R$ 714,04 (setup 300,00 + fiança 414,04) paga integralmente com setup_paid = 714,04 e guarantee_paid = 0 responde 422 PAYMENT_ALLOCATION_MISMATCH, ainda que a soma feche; e valor negativo em qualquer balde responde o mesmo (RN16).
  • [ ] CA57. AGENCY_USER lê collection_method, interest_percent_month e late_fee_grace_days em GET /api/v1/settings — os três saem sem máscara, por decisão registrada (RN10a).
  • [x] CA58. OPERATOR recebe 403 ao remover e ao editar o metadado do comprovante de uma baixa alheia, e o receipt_document_id da cobrança não muda (RN20a).

14. Plano de implementação

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

A ordem dentro da API não é a canônica por acaso, e vale dizer por quê: a classificação (9.1) tem de existir antes de qualquer agregado, porque resumo, filtro e exportação somam por ela; e a configuração (9.2) tem de existir antes da classificação, porque é de onde o canal sai. Quem inverter descobre isso ao escrever o primeiro teste.

Fase 1 — apps/api, a configuração

  • [x] TenantSettings: enum CollectionMethod e os três campos — model, schema (Create / Update / Response + json_schema_extra), migration meio_de_cobranca_e_encargo (RN8, RN10)
  • [x] PartnerAgency: collects_monthly — model, schema, migration imobiliaria_recolhe_mensalidades, a omissão por perfil na resposta e a guarda contra o SaaS admin (RN11, RN11a, seção 10.1)
  • [x] Testes: os campos entram e saem; a imobiliária não vê collects_monthly (CA28) e não o escreve (CA52); o SaaS admin não o escreve, nem no create nem no update (CA42)

Fase 2 — apps/api, a classificação

  • [x] Billing: os dois enums e as seis colunas de classificação; reference_month vira NOT NULL — model + migration classificacao_de_cobranca, com o passo de dados da seção 11 (RN1–RN5)
  • [x] GuaranteeService._gerar_calendario_de_cobranca passa a preencher (RN6); BillingService.create, idem para a avulsa (RN7)
  • [x] Testes antes do código (TDD): CA1, CA2, CA3, CA4, CA5, CA6, CA7
  • [x] Exercitar o downgrade de verdade, não só escrevê-lo

Fase 3 — apps/api, encargo e pagamento

  • [x] Função pura do encargo em app/domain/encargo.py (RN12), com os testes de borda da CA8 e CA9 escritos primeiro
  • [x] late_fee_projected / interest_projected na leitura (RN13) e o endpoint charge (RN14)
  • [x] BillingPayRequest e o pay (RN15–RN19, RN22)
  • [x] Colunas de baixa em Billing e migration baixa_detalhada (RN15)
  • [x] Document.billing_id: model, _VALID_ENTITY_TYPES (que deixa de derivar de _ENTITY_REPOS), os dois col_map, _owned_by_agency, router, migration comprovante_na_cobranca (RN20, RN21)
  • [x] BillingRepository.get_in_scope(billing_id, current_user), juntando Guarantee e aplicando tenant e imobiliária no mesmo where; e o ramo explícito de billing em _validate_entity, antes do caminho genérico. billing não entra em _ENTITY_REPOS (RN20)
  • [x] As duas guardas de escrita de comprovante, e a exigência de billings:update nos quatro caminhos de escrita — upload, edição, substituição de conteúdo e remoção (RN20a)
  • [x] Testes: CA10–CA17, CA30, CA31, CA39, CA40, CA41, CA43, CA47, CA48, CA51, CA53, CA54

Fase 4 — apps/api, atraso e agregados

  • [x] Segundo cron no worker, às 7h, com timeout=600 e run_at_startup=False (RN23); situation derivada (RN24)
  • [x] Filtro situation substituindo status (RN24a) e validação de reference_month (RN5)
  • [x] overdue_only (RN25), summary (RN26, RN27), export com openpyxl (RN29), e a guarda de tenant dos dois agregados (RN26a)
  • [x] billings:export no rbac_seed.py, com a subtração do ADMIN_GLOBAL comentada
  • [x] _monthly_financial troca o recorte (RN28) e o description do endpoint é atualizado no mesmo diff
  • [x] Os três action_url da Central passam a apontar para a aba de atrasados (seção 11) — item que não estava em fase nenhuma e é desta, porque é a RN24a que extingue o filtro para onde eles apontavam
  • [x] Testes: CA18–CA26, CA27, CA29, CA32, CA38, CA44, CA45, CA46, CA49, CA50
  • [x] Docstrings + Swagger (summary, description, response_description) e TAGS_METADATA da tag Cobranças

Fase 5 — apps/web

  • [x] PageTabs compartilhado, com o teste unitário dele
  • [x] Tela de Cobranças: abas, indicadores, resumo, filtros, as nove colunas, a fila
  • [x] BillingPayDialog nos quatro blocos, e BillingList do detalhe em oito colunas
  • [x] Parâmetros e Imobiliárias
  • [x] nav-badges passando a ser alimentado pela contagem, e atualizado após cada ação
  • [x] data-testid em todo elemento interativo (a lista está na seção 11)
  • [x] Desktop e mobile no mesmo passo; testes unitários ao lado dos arquivos
  • [ ] CA33–CA37 e CA55 cobertos pelo que couber em jsdom; o resto vai para o e2e

Fase 6 — apps/e2e

  • [x] Os cenários da seção 11, em tests/e2e/billings.spec.ts
  • [x] emitirEAssinar() passa a aceitar a imobiliária, e o arquivo ganha uma parceira própria com collects_monthly ligado — em vez de trocar o collection_method do tenant, que é compartilhado com a suíte inteira. O plano dizia "meio de cobrança e collects_monthly"; ver o Registro para por que só a segunda metade entrou
  • [x] Os testes da 0048 que a 0054 quebrou, adaptados no mesmo arquivo — a aba do mês recorta por competência, billings-status-filter virou billings-situacao-filter, o sentinela sem-competencia saiu, o link da Central passou a ser ?aba=atrasados e o filtro de Competência voltou ao mobile

Fechamento

  • [x] make preflight — verde em tudo menos a verificação de citação, cujos dez apontamentos são os falsos positivos do heurístico registrados no §18
  • [x] make test (a spec toca os dois apps) e make lint sem apontamento novo nos arquivos tocados — pytest 1224 verdes, web 438 em 54 arquivos, tsc limpo; no ruff, os arquivos tocados têm exatamente os mesmos apontamentos que têm na main (E501 e I001 de um repositório que nunca passou por ruff format, mais os F821 de forward-ref do SQLAlchemy)
  • [x] /devremote-rebuild de api, web e worker — a dependência nova e o job novo vivem na mesma imagem da API, e o container do worker precisa ser recriado
  • [x] Os specs e2e das telas tocadas, contra o dev-remote
  • [x] scrollWidth - clientWidth == 0 em 375px — virou asserção do próprio spec (billings.spec.ts, o painel de pagamento), em vez de um script de screenshot avulso: o que uma captura mostraria uma vez, o teste passa a conferir em toda execução
  • [x] A seção Domínio do CLAUDE.md da raiz ganha a generalização do canal — a pendência aceita da Parte I

15. Decisões em aberto

Uma, e ela não trava a implementação — trava o valor padrão de um parâmetro.

  • [ ] Quantos dias de carência o contrato do piloto concede? Decide: Marcus. A Parte I registra que o contrato da TXC concede carência — é o que dá lastro à RN10 e à RN12 —, mas o número não foi apurado. Não bloqueia a implementação: a coluna nasce anulável, e garantidora sem valor preenchido cai no caminho já especificado (encargo sem a parcela correspondente, CA9, com a tela dizendo que não está parametrizado, CA36). O que o número decide é o que o piloto vai digitar em Parâmetros no primeiro dia, não o que se constrói. Se a resposta chegar antes do fim da Fase 1, entra como valor inicial do tenant do piloto — nunca como server_default, porque carência de um contrato não é padrão de produto.

Todas as demais decisões que o laço das três partes levantou foram tomadas e estão no texto: o escopo em duas specs, o acesso da imobiliária, juros e carência nos parâmetros, a cobrança composta com valores discriminados, a origem do canal, o padrão de abas, o XLSX, o painel de pagamento completo, o recorte da aba Atrasados, a decomposição da entrada no resumo e a quinta FK do comprovante.

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 Nota Veredicto
1 4 7 6 7 Reprovada
2 2 5 9 7 Reprovada
3 1 5 8 7 Reprovada
4 1 5 10 7 Reprovada
5 1 3 6 7 Reprovada
6 0 2 6 8 Aprovada

Resultado: aprovada na rodada 6.

Triagem dos achados da rodada 6. Os dois Important e os seis Minor foram corrigidos antes de fechar — nenhum ficou em aberto. Os dois que pesavam: os outros três caminhos de escrita de documento (editar, substituir conteúdo, remover) entraram na tabela de permissões, porque a regra que os cobre estava escrita e a seção que deveria ser a fonte única de acesso não os listava; e a distribuição do pagamento ganhou piso e teto por balde, sem os quais a identidade dos indicadores não fechava "por construção" como a RN27 afirma — uma entrada paga integralmente podia alocar tudo em setup e produzir uma linha de resumo com pago maior que cobrado.

Seis rodadas, e o que elas encontraram. As notas foram 7, 7, 7, 7, 7 e 8, e cada uma das cinco primeiras achou um Critical novo — nenhum repetido. Quatro eram caminho de acesso real: o SaaS admin anexando arquivo em cobrança de cliente, a imobiliária escrevendo em cobrança da garantidora, o SaaS admin mudando por onde o dinheiro mensal de um cliente é cobrado, e os parâmetros de encargo saindo por um endpoint que ninguém tinha olhado. Um era de modelo: os agregados especificados sobre uma distribuição que não estava persistida. As duas assinaturas do defeito foram sempre as mesmas — afirmação sobre código existente que não foi reaberta na linha, e decisão escrita numa regra que não desceu para as seções 10, 11, 13 e 14. Vinte e nove regras e cinquenta e oito critérios que se citam dão quatro lugares para esquecer a cada correção; a varredura mecânica das duas assinaturas, adotada a partir da rodada 4, foi o que fez a sexta fechar.

Riscos aceitos: risco de acesso que a revisão apontou e se decidiu não fechar nesta spec, com quem decidiu e por quê. Vazio se não houver — e o esperado é que esteja vazio.

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 Nota Veredicto
1 apps/api — — — — —
2 apps/web — — — — —
3 branch — — — — —

Resultado: aprovada na rodada N da revisão de branch.

Defeitos aceitos: o que a revisão apontou e se decidiu não corrigir nesta spec, com quem decidiu e por quê. Vazio se não houver.

18. Registro

O preflight fica vermelho nesta branch, e não é defeito da spec

A verificação "Citação arquivo:linha aponta para o que diz" acusa dez parágrafos desta spec. Abri as dez linhas citadas: todas estão certas. O que dispara o alarme é o heurístico — ele pega o trecho de código mais longo do parágrafo e o procura no arquivo citado (scripts/preflight.py, linhas 963-970), e nesses dez o trecho mais longo pertence a outro assunto do mesmo parágrafo:

Arquivo e linha citados Trecho que o verificador compara De onde o trecho é
models/billing.py, linha 200 setup_paid + guarantee_paid + misc_paid + charge_paid == amount_paid a fórmula da RN16
routers/documents.py, linha 80 , quando positivo. Medi-la por prosa da RN19
services/document_service.py, linha 264 else: return o ramo do else da guarda da imobiliária, citado na frase seguinte
services/operations_center_service.py, linha 568 GET /api/v1/billings/summary um endpoint novo
routers/operations_center.py, linha 146 Content-Disposition: attachment o cabeçalho da exportação
routers/documents.py, linha 122 GET /api/v1/guarantees/{gid}/billings/{bid}/charge um endpoint novo
services/billing_service.py, linha 102 403 PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGS um código de erro novo
operations-center/index.tsx, linha 96 badgeCount: badges.overdueBillings código a escrever, não existente
services/partner_agency_service.py, linha 118 GET /billings?reference_month=sem-competencia um exemplo de URL
repositories/billing_repo.py, linha 155 GET /billings/reference-months um endpoint citado no mesmo item da seção 9.7

Os números desta tabela foram refeitos na Fase 3, e a causa vale registrar: as próprias implementações deslocam as linhas que a spec cita. O pay cresceu e empurrou a guarda do SaaS admin de 44 para 86; a guarda nova do comprovante empurrou a do dossiê de 221 para 245; as colunas de baixa empurraram notes de 120 para 201. Nove citações estavam de fato erradas ao fim da fase, e as nove foram reconferidas abrindo o arquivo. É a diferença entre o falso positivo do heurístico — que continua — e o apontamento verdadeiro que ele esconde: sem rodar o preflight depois de escrever o código, os dois se confundem.

A tabela escreve "linha N" em vez de arquivo:linha de propósito. Na primeira escrita ela usava a forma normal, e o verificador leu as nove linhas da tabela como nove citações novas — a documentação do falso positivo virou falso positivo. É pequeno e é engraçado, mas ilustra o que a seção inteira diz: a régua mede a forma, não o sentido.

O próprio verificador documenta ser um compromisso: ele não confere o trecho quando o parágrafo tem mais de uma citação, porque não há como emparelhar trecho e caminho (scripts/preflight.py, linhas 929-946). Estes dez são o caso simétrico — uma citação só, e um trecho que é de outra coisa.

A decisão, tomada com o usuário: seguir com a verificação vermelha e conferir à mão, a cada fase, se apareceu falha nova — em vez de quebrar nove parágrafos de um documento já aprovado para agradar um heurístico. Distorcer o texto para o verificador fechar seria trocar a coisa medida pela medida.

O verificador achou um erro real no caminho, e é por isso que ele vale: routers/documents.py linha 94 deveria ser 96 — a linha 94 é a assinatura da função, e o require_system_role("ADMIN_TENANT") está duas abaixo. Corrigido. Três outras citações foram reapontadas porque o código desta branch as moveu (partner_agency_service.py), e oito caminhos abreviados (billings/index.tsx linha 99, 0048/specification.md linha 476) foram expandidos para o caminho completo a partir da raiz — o verificador não os resolvia, e um leitor fora de contexto também não.

E vale registrar por que isto só apareceu agora: o preflight confere apenas os registros que esta branch escreveu, e enquanto a spec estava sem commit ela não entrava no diff — a verificação rodava e não a via. Ela passou a ser conferida no primeiro preflight depois do commit.

Fase 5 — o apps/web

O PageTabs nasceu compartilhado, e não local, porque a spec seguinte já o usa. O app não tinha abas dentro de tela em lugar nenhum; fazê-lo aqui como arranjo da tela de Cobranças significaria refazê-lo — diferente — quando o acerto com imobiliárias chegar. O que o componente prende, além do visual: a aba ativa vive na URL e só ela, a contagem vai dentro do rótulo (um badge ao lado apertaria a linha em 375px), e a semântica é tab/tablist, porque são recortes da mesma tela e não links para telas diferentes.

O vocabulário — tipo, canal e situação — ficou num arquivo só (billings/vocabulario.ts). Os mesmos rótulos aparecem em quatro lugares: a coluna e o filtro da lista, o resumo, o cartão do celular e o painel de pagamento. Escritos de novo em cada um, divergem na primeira correção de texto — e o canal é o vocabulário central desta spec.

O painel de pagamento perdeu a conta que fazia sozinho. Ele estimava a multa em amount × (1 + late_fee_percent / 100) e pré-preenchia o campo; juros e carência não existiam no front, então o número oferecido nunca era o que o contrato mandava cobrar. Agora a conta aberta vem do endpoint da RN14 e é refeita a cada mudança de data, que é a razão de o campo existir. O arquivo de teste dele mediu aquela estimativa por duas specs: foi substituído, não ampliado.

Três coisas que o dia mostrou, e que valem para a Fase 6:

  • O dublê de rede casa por "a última declaração vence", e a URL do resumo carrega os mesmos filtros da lista. Declarar só a lista num caso fazia o resumo receber uma página de cobranças no lugar dele, e a tela quebrava por defeito do preparo, não do produto. O conserto foi um helper que redeclara o resumo depois de cada troca de lista.
  • preencher() cola sem limpar. No painel de pagamento todo campo já chega preenchido pela conta aberta, então colar "1200" num campo com "1022.00" produz "1022.001200". Campos numéricos e de data pedem fireEvent.change.
  • O MemoryRouter dos testes não escreve em window.location. Que a aba vai para a URL se mede no e2e, onde há navegador de verdade e recarregar significa alguma coisa; aqui o efeito observável é a aba passar a ser a ativa.

A revisão da fase reprovou, sem Critical e com catorze Important, e a maioria era entrega faltando — não código errado. Vale registrar as três classes:

  1. Estado que a 7.3 descreve e eu não implementei. O resumo que falha virava cinco cartões de R$ 0,00, indistinguíveis de uma competência vazia — e para o ADMIN_GLOBAL, que toma 403 ali por desenho, a tela afirmaria "Previsto R$ 0,00" ao lado de uma lista cheia. Consertando isso apareceu um defeito pior que a revisão não viu: apiReq trata 403 antes de quem chamou, com window.location.href, e nenhum .catch() impede — o resumo sem tolerate403 expulsaria o SaaS admin da tela inteira por causa de um painel acessório. Faltavam também a CA36 (o estado "encargo não parametrizado", que é o de toda garantidora no dia seguinte a esta spec), a marca de abatido na lista, o vazio próprio da fila e a coluna "há quantos dias venceu".
  2. Uma regressão que a fase criou. O campo collects_monthly viajava sempre, e a API recusa a requisição inteira quando ele chega do administrador da plataforma (RN11a) — então o SaaS admin perdia a capacidade de corrigir um telefone de imobiliária, que sempre teve. O campo passou a não ser renderizado nem enviado para ele.
  3. Os data-testid da seção 11 realinhados. Ela é o contrato que a Fase 6 vai usar, e eu havia inventado nomes próprios no lugar dos que ela fixa. Os 27 da seção estão todos no código com o nome dela — conferidos um a um na sexta rodada. O que continua existindo são identificadores fora da lista (indicador-* em IndicadoresDaCompetencia.tsx, nove pay-* em BillingPayDialog.tsx), e é assim mesmo: a seção 11 diz o que o e2e vai usar, não proíbe outros, e o CLAUDE.md exige data-testid em todo elemento interativo. A frase anterior dizia "foram todos realinhados", que se lia como "nenhum outro sobrou" — não era verdade. Um deles era pior que renomeação: o do seletor de Meio de cobrança estava em slotProps.htmlInput de um TextField select, onde o MUI não o repassa — o identificador era declarado e nunca chegava ao DOM, que é exatamente a armadilha que agencies/form.tsx já documentava.

Fecharam junto duas regras sem teste (a troca de aba preservando filtros comuns, e o resumo que falha) e seis Minor, entre eles o URL.createObjectURL sem revoke no comprovante e o desmarcar do abono, que deixava o encargo em zero e a multa devida sem ser cobrada — com a soma fechando, então nada avisava.

A segunda rodada reprovou de novo, com seis Important da mesma família — e o mais grave era, outra vez, um 403 que expulsa a pessoa da tela. Desta vez no Painel: a contagem do badge chamava apiReq sem tolerate403, e um perfil com operations_center:read e sem billings:list_all seria ejetado da home por causa de um badge. O mesmo mecanismo, o mesmo conserto, duas telas diferentes — vale como regra para a Fase 6 e para quem vier depois: toda leitura acessória que pode tomar 403 precisa de tolerate403, porque o handle403 age antes de quem chamou e nenhum catch o alcança.

No mesmo achado, um defeito de dependência: a contagem vivia dentro do loadAll, um useCallback sobre os filtros do Painel, e user chega depois do primeiro render — fechado ali, nunca era relido, e o badge ficava em zero até alguém abrir Cobranças. Que é exatamente o caso que ele existe para cobrir. Virou efeito próprio, com user na dependência.

Fecharam junto:

  • Trocar a data apagava o que a pessoa tinha digitado na distribuição. A 7.3 diz que só a conta e o total recarregam; quem ajustou a fiança para o combinado e depois corrigiu a data perdia o ajuste sem aviso. Agora a primeira resposta preenche tudo e as seguintes mexem só no encargo, com o recebido acompanhando a diferença.
  • A recusa da API virava alerta genérico, e a RN16 recusa por balde nomeado: o erro passou a cair no campo que o causou, com a faixa recebendo só o que não achou campo na tela.
  • <Link href="/settings"> apontava para fora da aplicação — o app roda sob basename (/app/), e a âncora crua levava a um caminho que não existe. Era, ironicamente, o link que a CA36 prescreve.
  • Faltava a linha "competência sem nenhuma cobrança" da 7.3: quem abria um mês vazio lia que "cobranças aparecem aqui automaticamente quando uma garantia é assinada", o que é falso — elas existem, em outro mês.
  • Três regras sem teste: o estado sem encargo parametrizado (CA36), o resumo recolhendo no mobile (arquivo de teste novo), e a guarda do SaaS admin que consertou a regressão da rodada anterior — esta com red-green verificado.

A terceira rodada achou o melhor defeito da fase, e ele tem uma lição que vale mais que o conserto. O painel de pagamento ficava intransponível entre 21h e meia-noite: a tela calculava "hoje" com toISOString(), que é UTC, e a API decide data futura em America/Sao_Paulo. Das 21h de Brasília em diante o campo nascia com amanhã, /charge respondia CHARGE_DATE_IN_FUTURE, a conta aberta ficava nula — e como a confirmação exige a conta, o botão nunca habilitava. Três horas por dia, ninguém registrava pagamento nenhum, com a tela sem dizer que o remédio era trocar a data.

A lição é que o defeito já tinha sido resolvido neste repositório. A assinatura da garantia (spec 0038) o encontrou, entendeu e consertou, com um comentário que explica exatamente isto — e o painel de pagamento o reintroduziu por não saber que a solução existia, a dois arquivos de distância. O conserto foi promover hojeLocalISO a shared/utils/dateUtils.ts, com o porquê escrito lá; agora são três consumidores, e o próximo encontra a função antes de reescrever o erro.

Fecharam junto:

  • A contagem de atrasados incluía cobrança removida quando Mostrar removidos estava ligado — e ia para o badge do menu, onde ficava pelo resto da sessão dizendo que há mais atraso do que há. É a única contagem da spec que tinha de se refazer sozinha, e era justamente ela que mentia.
  • A recusa do comprovante era engolida por um catch sem parâmetro: tipo não aceito, arquivo grande demais e teto de anexos nunca chegavam à tela, e a pessoa clicava em "Tentar de novo" com o mesmo arquivo. E a frase apontava para um caminho que não existe ("anexe depois pela cobrança") — algo que um comentário do mesmo arquivo já dizia ser falso.
  • O detalhe da imobiliária ficou de fora da entrega: quem abria a parceira para conferir a cláusula de recolhimento não a encontrava.

A quarta rodada achou um Critical, e ele foi criado pelo conserto da rodada 2 — o que é a lição. A rodada 2 apontou que a recusa da API virava "alerta genérico"; o conserto passou a mapear os details por campo e a suprimir a faixa quando todo detalhe achava campo na tela. Só que os details deste endpoint não traziam message — eram {field, max} —, então o campo acendia com texto vazio e a faixa sumia. Resultado: a única tela que move dinheiro recusava em silêncio, com o botão voltando a habilitar como se nada tivesse acontecido. O alerta genérico, que era o defeito original, ao menos dizia alguma coisa.

O conserto foi dos dois lados, e o da API é o que importa: todo details passa a levar field e message (distribuicao_de_baixa.py), com o porquê escrito lá — o field diz onde acender, a message diz o quê, e nenhum cliente deveria montar texto a partir de max/min/expected. Do lado da tela, a regra virou a segura: a faixa recebe sempre a mensagem da API, e nunca se troca uma pela outra.

O teste que teria pego isso não existia, e passou a existir — com red-green sobre o defeito exato.

Fecharam junto:

  • A data de vencimento saía um dia antes nas linhas que esta fase escreveu: new Date("2026-07-20") é meia-noite UTC, e em São Paulo isso é 21h do dia 19. Virou formatarDataISO em dateUtils, ao lado de hojeLocalISO — o segundo defeito de fuso desta fase, e a segunda função a nascer compartilhada por causa dele.
  • O comprovante não usava os limites do produto: accept próprio e nenhuma guarda de tamanho, com ACCEPT_DE_DOCUMENTOS e MAX_BYTES_DE_DOCUMENTO já publicados em components/shared. Uma foto de 12 MB — o caso que a 7.2 diz favorecer — subia, estourava no servidor e voltava como erro com o pagamento já gravado.
  • As duas regras de contagem sem teste: "só quem tem tenant pede" (fronteira plataforma × cliente) e "a contagem se refaz depois de cada ação", que é a promessa central da 7.A. As duas com red-green.

A quinta rodada achou um Critical de leitura, e ele veio de copiar o vizinho. Em edição, "A imobiliária recolhe as mensalidades" mostrava sempre "Não", qualquer que fosse o valor gravado: o campo nascia com defaultValue="false" e register em slotProps.htmlInput, que chega ao input escondido do Select — cujo valor visível é estado interno do MUI. O reset() da carga escrevia o formulário e o ref, e o Select nunca relia.

O efeito é pior que "campo errado": a coluna da lista dizia Recolhe, o detalhe dizia Sim, e o formulário dizia Não — três lugares, duas respostas, para o mesmo registro. E quem quisesse desligar o recolhimento veria "Não" já selecionado e não teria motivo para clicar.

O arranjo foi copiado do seletor de Status ao lado, que tem o mesmo defeito e é anterior a esta spec. Fica registrado como pendência — corrigi-lo de carona seria mexer em comportamento que nenhuma regra desta spec cobre. A lição é a mesma do fuso: o vizinho nem sempre é o exemplo.

Fecharam junto:

  • Esvaziar juros ou carência não apagava nada. exclude_none=True no update descarta o null explícito — o que está certo para campo cujo vazio não significa nada, e errado para estes dois, onde vazio é uma resposta ("não cobro esta parcela"). Quem preenchia 1% e depois apagava recebia "salvo com sucesso" e via o 1% voltar no recarregamento. A exceção usa exclude_unset para distinguir "mandei vazio" de "não mandei", e os dois lados da regra têm teste.
  • A guarda do SaaS admin continuava sem teste, porque renderizar e enviar são caminhos separados: apagar o if do envio deixava tudo verde e devolvia a regressão. A montagem do corpo virou função pura exportada — é o que torna a regra afirmável em duas linhas.
  • O message novo nos details não tinha teste na API, e a única proteção contra a recusa muda voltar era um stub no front. Agora as três recusas com campo são verificadas na origem, com red-green.
  • Parâmetros e a lista de Imobiliárias não tinham cobertura nenhuma dos campos que esta fase acrescentou.
  • A contagem "há N dias" usava o relógio do navegador, enquanto a fila foi ordenada pelo servidor em São Paulo — em outro fuso os dois divergiriam em um dia. Terceiro defeito de fuso da fase, e o terceiro a se resolver reusando o que já existia.

A sexta rodada aprovou — nota 8, nenhum Critical —, e os três Important dela apontavam todos para o mesmo buraco: regra que embarcou sem rede justamente onde esta fase já tropeçou. Nenhum virou pendência aceita; os três foram fechados, com os Minor junto.

  • O description do PATCH /settings ensinava o contrário do código. Ele ainda dizia "PATCH não os devolve a nulo — quem quer zerar envia 0", que era a decisão da RN10 antes de a Fase 5 descobrir que 0 e null não são a mesma coisa (0 é multa de 0%; null é "não parametrizado", e é o que a CA36 mostra na tela). Um Swagger que envelhece é ruído; um que instrui o integrador a fazer a coisa errada é defeito. A descrição e a RN10 foram reescritas.
  • As duas funções de fuso não tinham teste — e não teriam como ter. A suíte do apps/web rodava em UTC, onde meia-noite UTC e meia-noite local são o mesmo instante: new Date("2026-07-20") acerta o dia, toISOString() acerta a data, e os três defeitos de fuso desta fase ficariam verdes. Fixar TZ=America/Sao_Paulo no vite.config.ts põe a suíte no fuso em que o produto roda, que é onde essa classe de defeito existe. Com isso as duas funções ganharam red-green de verdade — revertidas para a forma "óbvia", ficam vermelhas.
  • A metade do web da regra de apagar juros/carência (mandar null explícito) não tinha teste: a API sabia apagar e ninguém mais lhe pedia. Mesma assimetria da rodada 5, mesmo conserto.
  • competenciaCorrente() ainda lia o relógio do navegador — quarto defeito de fuso da fase, no último dia do mês depois das 21h: a tela abriria na competência seguinte, com os indicadores zerados e a lista vazia, sem dizer por quê.
  • O dinheiro das recusas saía em formato americano. R$ 1022.00 como helperText, encostado no R$ 1.022,00 que o apps/web formata — duas grafias do mesmo número no mesmo formulário. O _brl de services/credit.py subiu para app/domain/moeda.py em vez de virar a segunda cópia.
  • Fecharam junto os dois details sem teste (abono com encargo, piso negativo), o ramo de desmarcar o abono e um ternário morto em agencies/form.tsx.

E uma afirmação do próprio §18 estava errada. O item 3 da primeira rodada dizia que os data-testid inventados "foram todos realinhados"; os 27 da seção 11 estão todos no código com o nome dela, mas as famílias indicador-* e pay-* continuam existindo — o que é correto (a seção 11 diz o que o e2e usa, não proíbe outros), e o registro é que estava dizendo "feito" sobre o que não foi. Corrigido no lugar.

Uma limitação registrada, não contornada: o número da aba entra no nome acessível como "Atrasados(3)", sem separador. O cálculo do nome colapsa qualquer espaço entre dois nós de texto, inclusive o não separável; o espaço visual é de CSS. O número é anunciado junto do rótulo, que é o que a 7.2 pede, e a asserção do teste passou a ser sobre o elemento próprio da contagem em vez do nome.

Uma observação de desenho que a spec não resolve, e não foi resolvida aqui: na linha de uma cobrança avulsa, "Avulsa" aparece duas vezes — na coluna Parcela, porque não há mensalidade a numerar, e na de Tipo, porque é o tipo dela. As duas saem do que a 7.1 prescreve para cada coluna. Fica registrado como pendência de desenho, não como divergência corrigida no código.

Fase 4 — o atraso e os agregados

A situação e o filtro dela foram escritos no mesmo módulo, e isso é a decisão de desenho da fase. São duas metades da mesma regra em linguagens diferentes: a derivação em Python diz o que a coluna mostra, a condição SQL diz o que o filtro devolve. Separadas, divergem em silêncio — o filtro Aguardando a imobiliária passaria a trazer linhas que a coluna não marca assim, e ninguém percebe até alguém conferir à mão. O precedente é app/domain/guarantee_status.py, que já mantém estado_efetivo ao lado da expressão SQL pelo mesmo motivo. O teste que prende isso é test_os_dois_filtros_de_pendente_particionam_sem_sobra: somadas, as duas listas têm de dar todas as pendentes, sem repetir nenhuma.

O resumo é soma em Python sobre as linhas, não GROUP BY — e a razão não é preferência. A entrada composta vira duas linhas do relatório, com a parcela contada só na primeira; um GROUP BY fee_type, channel devolveria uma linha SETUP_AND_GUARANTEE com os R$ 714,04 juntos, que é exatamente o relatório que esta spec existe para acabar. E o encargo_corrido precisa da função pura da RN12 aplicada parcela a parcela: exprimi-lo em SQL seria escrever a mesma conta uma segunda vez, em outra linguagem, para as duas divergirem depois. O custo é carregar um mês de uma garantidora, e é aceitável porque a competência é obrigatória no endpoint.

O abatido por linha é medido, não rateado. discount_amount é da cobrança inteira, e dividi-lo entre adesão e fiança exigiria um percentual inventado. Em vez disso, cada linha soma o que faltou naquele balde (cobrado − pago, nas pagas) — com o efeito de cobrado = pago + abatido + a_receber fechar em cada linha, e a soma das linhas dar exatamente o discount_amount da cobrança.

A exclusão da cancelada vale em dois lugares de propósito. A consulta a filtra (não faz sentido carregá-la) e a função pura a descarta de novo. É duplicação deliberada: é essa exclusão que faz o previsto bater com o da Central de Operações, e uma regra que só vive numa cláusula where está a um refactor de sumir sem teste que a segure. Na função pura ela é testável sem banco — e o primeiro teste que escrevi para ela falhou, porque o contrato que eu mesmo havia escrito dizia que quem filtra é o chamador. A regra é da spec; o contrato cedeu.

Três coisas que o dia de trabalho mostrou e valem para a Fase 5:

  • start_date=date(2026, 1, 15) não é cenário limpo. A garantia nasce em janeiro e o calendário cobre doze meses, então boa parte das parcelas já venceu em relação a hoje. Os primeiros testes de fila mediram oito vencidas de brinde e falharam por isso. O helper passou a neutralizar o calendário — tudo a vencer daqui a um ano, pendente e no boleto — e cada teste move só o que estuda; o que ele não move fica comprovadamente fora do recorte.
  • db_session.expire_all() seguido de objeto.id estoura com MissingGreenlet: ler um atributo expirado dispara carregamento preguiçoso fora do greenlet do SQLAlchemy async. Os ids saem antes do expire_all().
  • A forma da resposta da Central é current_month + history, não uma lista monthly. Custou uma rodada de teste vermelho.

A revisão da fase achou dois Critical, e os dois são instrutivos por motivos diferentes.

O primeiro não estava no código: estava no que faltou junto dele. openpyxl entrou no pyproject.toml e não entrou no uv.lock — e os dois Dockerfiles instalam com uv sync --frozen, que lê o lock e ignora o pyproject. O próximo /devremote-rebuild ou /ship derrubaria a API inteira com ModuleNotFoundError no import de app.main, não só a exportação, porque o serviço importa o módulo de planilha no topo.

O que mascarou o problema merece registro, porque é a armadilha que o CLAUDE.md da API descreve: eu rodei uv sync dentro do container para instalar a biblioteca, e aquilo reescreveu o uv.lock de dentro dele, não o da árvore. Pior: o docker compose up -d seguinte não recriou o container — a saída dizia "Starting", não "Recreated" —, então o que estava no ar continuou sendo o sistema de arquivos remendado à mão, com a biblioteca presente por acidente. A conferência que pega isso é rodar a imagem limpa: docker run --rm --entrypoint /app/.venv/bin/python <imagem> -c "import ...". O conserto foi gerar o lock, trazê-lo para a árvore e refazer o build com --force-recreate.

O segundo quebrava a identidade que é a razão de ser do relatório, e com dado que já está no ambiente compartilhado. Toda baixa anterior a esta spec tem amount_paid e os quatro baldes nulos; o resumo somava previsto += amount e recebido += 0, e a identidade da RN27 caía pelo valor inteiro da parcela. Pior que isso: o abatido da linha era medido (cobrado − pago) e o do indicador vinha de discount_amount, então a mesma resposta trazia R$ 0,00 no painel e o valor cheio no total da tabela — duas somas da mesma coisa discordando na mesma tela, que é exatamente o defeito que esta spec existe para acabar, reintroduzido pela porta do dado antigo.

A decisão de como tratar a baixa legada voltou para o usuário, por ser de negócio e ter mais de uma resposta defensável. Ele escolheu reconstruir na leitura, e a RN27 ganhou a cláusula. O abatido passou a ser medido nos dois lugares, o que resolve as duas metades de uma vez. Conferido contra a linha real do dev-remote que a revisão usou: identidade fecha, e os dois abatido concordam.

Fecharam junto: o negativo de tenant que faltava nos dois agregados (o escopo estava certo por construção, mas nada avisaria se alguém trocasse current_user.tenant_id por tenant_scope, que devolve None para quem não tem tenant e viraria "sem filtro"); o teste do agendamento do cron (apagar a linha deixava a suíte verde e a inadimplência deixava de ser carimbada — a mesma falha silenciosa que a 0038 descreve para o seed); e a data única por requisição, que o comentário prometia e o código não cumpria.

A segunda rodada de revisão aprovou sem Critical, e os três Important dela têm um fio comum: a mesma invariante escapando por uma porta lateral.

  • O resumo e a exportação não aceitavam situation, embora a RN26 e a RN29 digam "os mesmos filtros da lista". Com a tela filtrada em Aguardando a imobiliária, o arquivo baixado traria a competência inteira — o anexo dizendo outra coisa que o relatório de onde saiu.
  • As duas abas da planilha mediam "Pago" e "Abatido" de jeitos diferentes. A aba de parcelas gravava amount_paid e discount_amount crus; a de resumo usava os valores medidos. Numa baixa com encargo, somar a coluna de uma não dava o total da outra — que é a primeira conferência que quem abre a planilha faz. O conserto foi expor principal_e_encargo_recebidos e abatido_de no módulo do resumo e usar a mesma medida nos dois lugares; a aba de parcelas ganhou a coluna Encargo recebido, que antes ficava escondida dentro do Pago.
  • Os três action_url da Central apontavam para ?status=OVERDUE, filtro que a RN24a extinguiu nesta fase. Era item sem dono — a seção 11 o descreve e nenhuma fase do plano o listava —, e ficou aqui porque é esta fase que o quebra. O link continuaria "funcionando" em silêncio: o FastAPI ignora query param desconhecido, e a tela abriria a competência corrente sem filtro nenhum.

A suíte fechou 1217 verdes e nenhum vermelho na execução final, com a imagem consolidada — os dois intermitentes passaram nesta rodada, o que é o outro lado da moeda da mesma instabilidade. Nas execuções anteriores da fase foram 1214 e 1206 verdes, cada uma com um vermelho — desta vez test_partner_agency_status_filter.py::test_alcanca_quem_esta_fora_da_primeira_pagina, o outro dos dois intermitentes do CLAUDE.md, com a mesma assinatura de cara-ou-coroa (3 verdes e 2 vermelhos em 5 execuções isoladas). Nenhum dos dois arquivos foi tocado por esta branch. Vale registrar que, ao longo das Fases 3 e 4, os dois apareceram — e o search, duas vezes, sempre com a mesma proporção isolada (3 e 2 numa das medições, 3 e 2 na outra) — o que é exatamente a proporção que aquele texto descreve, e a razão de ele mandar rodar cinco vezes antes de concluir qualquer coisa.

A dependência nova obrigou rebuild das duas imagens. openpyxl entra no api e, pelo mesmo Dockerfile, no worker — que não o usa. E a permissão nova (billings:export) é dado de seed, não de schema: entra sem migration nenhuma, e o boot da produção não roda seed. O seed foi rodado no dev-remote depois do rebuild; no deploy, o /ship já o roda sempre, o que é a razão de aquele passo existir (spec 0038).

Fase 3 — o encargo e a baixa

A ordem em que o pagamento recusa foi decidida aqui, e é desenho — não sobrou da implementação. Mais de uma condição pode valer na mesma chamada, e quem recebe o erro merece a mais informativa. Ficou: o abono primeiro (RN18), depois o teto total (RN19), depois soma, piso e tetos por balde (RN16). A razão é um caso concreto que apareceu escrevendo os testes — registrar hoje um recebimento de ontem de R$ 1.022 numa parcela cujo encargo de ontem era zero. Os dois erros são verdade ali; PAYMENT_EXCEEDS_DUE dizendo "o teto é R$ 1.000,00" diz o que a pessoa precisa saber, e PAYMENT_ALLOCATION_MISMATCH não. O teto anunciado é, aliás, o que prova que o encargo saiu da data informada: por hoje ele seria R$ 1.022.

Três decisões que a spec não tomou por escrito, tomadas aqui:

  • Cobrança PAID ou CANCELLED projeta encargo zero, com os dias de atraso reais. A RN13 manda projetar "na data de hoje" sem dizer o que fazer com a parcela fechada — e juros correndo sobre uma parcela quitada diria, na tela, que o inquilino ainda deve. Os dias continuam verdadeiros porque descrevem o atraso que houve; só o dinheiro vai a zero.
  • "Garantidora sem parâmetro" é a que não tem TenantSettings, não a que tem o campo nulo. late_fee_percent é NOT NULL com default de 2% (tenant_settings.py, linhas 64-66), então não existe garantidora com multa nula — o primeiro teste escrito para a RN12 partia dessa premissa e estava errado. O caso real é a linha inteira ausente, e é o que o teste passou a exercitar.
  • O comprovante em lote usa max(id) por cobrança. A RN20b garante um só, removendo o anterior no upload; o agregado é rede contra o caso que não deveria existir, e devolver um determinístico é melhor que estourar.

_serializar deixou de ser @staticmethod. A leitura passou a derivar três campos que vêm do banco — os dois do encargo (RN13) e o comprovante (RN20) —, e os três são pré-carregados em duas consultas por página, não por linha: uma para os parâmetros dos tenants presentes, outra para os comprovantes das cobranças presentes. Sem isso, uma página de cinquenta cobranças faria cem consultas.

O corpo do pay mudou, e cinco testes de outras specs mudaram junto — de forma, não de intenção. Eles enviavam {"amount_paid": ...} e passaram a montar a distribuição a partir da composição que a própria API devolve, por um helper (_corpo_de_baixa). Um deles ganhou correção de verdade: test_filtros_da_lista_cross_garantia escolhia a entrada de adesão por sorted(...)[0], e desde a Fase 2 ela divide o vencimento com a mensalidade 1 — a posição empatava. Passou a escolher por fee_type.

As duas migrations foram exercitadas nos dois sentidos (upgrade, downgrade, upgrade) contra o banco do dev-remote, e as sete colunas da baixa foram conferidas em information_schema depois.

A revisão da fase achou um Critical, e ele é do tipo que a suíte não pode pegar. A consulta que carrega os comprovantes de uma página usava func.max(Document.id) com GROUP BY — e documents.id é uuid, tipo para o qual o PostgreSQL não tem agregado max. Rodada contra o banco do dev-remote, ela responde function max(uuid) does not exist; no SQLite dos testes ela passa, porque ali max() aceita qualquer coisa. Como a chamada está em _preaquecer_parametros, que atende toda leitura de cobrança — lista agregada, lista embutida, detalhe e a resposta de create, pay, update, mark_overdue e restore —, a tela de Cobranças inteira responderia 500 no primeiro acesso depois do deploy, com 1153 testes verdes.

O conserto foi tirar o agregado: a consulta devolve (billing_id, id) ordenado por (created_at, id) e o dicionário é montado em Python, o último vencendo. A RN20b garante um comprovante ativo por cobrança, então o conjunto é do tamanho da página. A lição para as fases seguintes é a diferença de dialeto: função de agregação sobre uuid, ARRAY, DISTINCT ON e ILIKE se comportam diferente nos dois bancos, e teste verde em SQLite não é prova para nenhum deles. Quando a consulta usar algo assim, rodá-la uma vez contra o dev-remote custa segundos.

O downgrade do comprovante contrariava a decisão escrita da spec, e foi revertido para ela. A seção 12.9 decidiu que ele apaga os documentos cujo único dono era a cobrança; a implementação só derrubava a coluna, argumentando no docstring o contrário. A spec ganha: um documento com as cinco FKs nulas não aparece em listagem nenhuma, não é alcançável por escopo de imobiliária e continua contando no resumo de armazenamento do tenant — invisível, inalcançável e cobrado. A condição é conservadora: só apaga a linha cujas outras quatro FKs já são nulas.

Duas regras estavam sem teste que as defendesse, e as duas passaram pelo red-green completo (remove a regra, teste vermelho; restaura, verde):

  • o PUT /documents/{id}/content — o quarto caminho de escrita da RN20a, o único sem par óbvio com create ou delete, e por isso o mais fácil de perder num refactor;
  • a projeção zero em cobrança PAID/CANCELLED, que é uma das três decisões que este registro documenta. Sem teste, a decisão registrada e o código podiam divergir em silêncio.

A segunda rodada de revisão não achou Critical, e o que ela cobrou foi contrato publicado. Cinco decorators alterados tinham ficado com o Swagger da versão anterior — o pay, que foi de um campo para dez e ganhou quatro erros novos, ainda dizia "o campo amount_paid deve ser o valor efetivamente pago"; os quatro caminhos de documento não mencionavam billing nem os três 403 da RN20a. A regra do apps/api/CLAUDE.md é clara ("mudança de comportamento, permissão ou parâmetro atualiza a description"), e aqui não é higiene: as seções 12.5 e 12.6 nomeiam esses erros como parte do contrato. Um integrador lendo o Swagger montaria o corpo errado.

Mais duas decisões de implementação, registradas:

  • A substituição do comprovante descarta o BYTEA da via anterior, e não só marca a remoção lógica que a RN20b pede. É o mesmo desfecho do delete() do serviço, e a razão é a mesma: o conteúdo mora na própria linha, e guardar cada correção dobraria o armazenamento contado para o tenant. Ganhou evento de auditoria próprio (DOCUMENT_REPLACED_BY_NEW_RECEIPT) porque a revisão apontou o buraco: o delete() explícito registra, e este caminho — o único em que ninguém pediu a remoção — não registrava nada.
  • BILLING_RECEIPT_REQUIRES_UPDATE é código novo, acrescentado à seção 12.6. A RN20a exigia billings:update sem nomear a recusa, e ForbiddenError cru sai como FORBIDDEN — a tela não distinguiria "seu perfil não dá baixa" de "a plataforma não movimenta dinheiro de cliente".

Uma terceira regra estava sem teste que a segurasse, e foi pelo mesmo red-green: a cláusula da RN15 de que notes acrescenta em vez de substituir. O único teste que enviava notes na baixa usava uma cobrança com observação nula — onde acrescentar e substituir dão o mesmo resultado. O caso real é a avulsa que nasce explicando por que existe e perde essa frase quando o financeiro dá baixa.

A suíte fechou 1154 verdes e um vermelho conhecido, na imagem reconstruída ao fim da fase. O vermelho é test_partner_agency_search.py::test_busca_alcanca_quem_esta_fora_da_primeira_pagina — um dos dois intermitentes que o CLAUDE.md registra, e ele deu a assinatura exata que está lá descrita: 3 verdes e 2 vermelhos em 5 execuções isoladas. Não é regressão desta branch; o arquivo não foi tocado e o diff não alcança o que ele exercita.

Duas execuções anteriores da suíte valem como registro de método. A primeira deu 1152 verdes e um vermelho em test_retorno_analise_imobiliaria.py::test_imobiliaria_nao_devolve_analise_da_concorrente, que não é um dos intermitentes conhecidos. A evidência barata do CLAUDE.md foi aplicada na ordem: o arquivo não mudou na branch, o diff não toca o que ele exercita, e ele passou 5 de 5 isolado e 29 de 29 no arquivo inteiro. A execução completa seguinte passou limpa — e é ela que decide, porque nenhum desses passos sozinho decide.

Fase 2 — a classificação

A revisão da fase achou um Critical no passo de dados da migration, e ele é o tipo de defeito que nenhuma suíte pegaria. A entrada de cada garantia era eleita por DISTINCT ON ... ORDER BY due_date, id; com charge_first_month_upfront desligado, a geração grava a entrada e a mensalidade k=1 na mesma data, e o desempate caía em b.id, que é UUID aleatório. Metade das garantias nessa configuração teria a mensalidade eleita como entrada, recebendo setup_amount = initial_fee sobre um amount que é monthly_value — a invariante da RN2 quebrada em silêncio, sem nada no sistema detectar depois.

A evidência de execução não cobria o ramo, e é isso que o torna instrutivo: as doze cobranças do dev-remote são todas do caso composto, onde não há empate, então a migration rodou limpa sobre dado real e não provou nada sobre o outro caso.

O desempate passou a ser (b.amount IS DISTINCT FROM g.total_upfront), que ordena primeiro a linha cujo valor é o total_upfront — a entrada verdadeira. Medido, 200 execuções de cada versão sobre ids aleatórios: a antiga escolheu a linha certa 101 vezes e a errada 99; a nova, 200 de 200. Quando os dois valores coincidem, as duas linhas são intercambiáveis e a classificação sai correta de qualquer jeito.

Fecharam junto:

  • Cobrança removida ficou de fora dos três UPDATE, inclusive da partição que numera — contada ali, ela faria installment_total valer 13 num plano de 12 e deslocaria as posições, divergindo do meses_de_vigencia que o código de geração grava.
  • A invariante da RN2 virou guarda de código (garantir_composicao_coerente), chamada na geração e no lançamento avulso. A RN a chamava de "verificada no service em toda escrita" e não havia verificação nenhuma — e é justamente ela que teria acusado o Critical.
  • O Swagger do POST .../billings passou a dizer que a avulsa nasce MISC, com canal do meio da garantidora e competência derivada do vencimento.
  • Saíram as duas últimas menções ao sentinela revogado, uma delas um reference_month IS NOT NULL que virou código morto.
  • Três testes fracos foram reforçados: o do canal-padrão (que passaria com o channel removido da geração, por ser o server_default) ganhou a conferência do parâmetro; o da competência passou a prender a obrigatoriedade no banco, não só o preenchimento; e a terceira linha da tabela da RN9 — garantia sem imobiliária — ganhou teste, que não existia porque o helper sempre cria uma.

A rodada 2 aprovou (nota 8) e achou o mesmo mecanismo num ramo que faltava: a eleição da entrada supunha que a entrada existisse. Cobrança é removível desde a spec 0049, e com a entrada removida a mensalidade seguinte era eleita e recebia a composição dela. O conserto trocou o desempate por uma definição: só a cobrança cujo amount é o total_upfront da garantia pode ser a entrada. Se ela não existir mais, nenhuma é eleita, todas caem em fiança, e a invariante continua valendo. Medido em SQL, 100 execuções por cenário: adesão pura elege sempre a linha certa; entrada removida elege NULL.

Isso permitiu desfazer os filtros deleted_at IS NULL que a rodada 1 tinha pedido — e desfazer era o certo: classificar e numerar são propriedades da cobrança, não do estado dela. Uma removida restaurada dentro da janela de 60 dias volta classificada, e mantê-la na partição é o que faz installment_total continuar sendo os meses de vigência, igual ao que a geração grava. A justificativa que a docstring dava para excluí-la, aliás, não correspondia ao SQL — o revisor mostrou que o cenário descrito ali não acontecia.

Fecharam junto: a invariante mudou de casa para app/domain/composicao_de_cobranca.py (era regra de domínio morando num service, e obrigava guarantee_service a importar de billing_service); o bloco Attributes: do modelo ganhou as seis colunas, que o AutoAPI publica; e a avulsa com collection_method = PIX ganhou teste — antes o MISC só era exercitado com boleto, e o que garantia o outro valor era compartilhar código, que é bom argumento de desenho e prova ruim.

Duas arestas ficam registradas, e nenhuma é desta fase resolver:

  • BillingService.update altera due_date sem tocar reference_month. Com a RN5 a competência passou a ser derivada do vencimento na avulsa, então mover o vencimento para outro mês deixa a cobrança numa competência que não é a dele. A spec não legisla sobre isso, e legislar aqui seria decidir no meio do código o que o portão não decidiu.
  • A tela ainda envia reference_month=sem-competencia (apps/web/src/pages/billings/index.tsx:65), que agora responde 422. É trabalho da Fase 5, e o teste unitário do web que fixa esse comportamento continua verde porque mocka a API — ele não vai avisar ninguém. Está anotado para ser trocado junto.

Divergência em relação ao planejado: nenhuma no escopo. Duas asserções da spec 0048 foram atualizadas, não enfraquecidas, porque a RN5 revoga a RN8 daquela spec: a avulsa lançada sem competência agora recebe a do vencimento, e o sentinela sem-competencia responde 422 em vez de devolver lista vazia fingindo que ainda vale. As duas passaram a provar mais do que provavam.

Fase 1 — a configuração

Sem divergência em relação ao planejado. Um detalhe que o plano não previa e o teste pegou: a omissão de collects_monthly para a imobiliária não funciona só com o schema — o response_model do router recoage a resposta e devolve o campo. Foi preciso trocar os quatro decorators de partner_agencies.py por response_model=None mais responses, que é exatamente o que o router de cobranças já fazia pelo mesmo motivo (apps/api/app/api/v1/routers/billing.py:191-198, a omissão de notes). O teste test_imobiliaria_nao_ve_collects_monthly passou a valer a partir daí — antes ele passava porque o campo simplesmente não existia.

A revisão da fase (rodada 1, nota 7) achou um defeito que a suíte verde escondia, e vale registrar porque a causa é sutil: PartnerAgencyCreate.collects_monthly nasceu como bool com default False, então model_dump(exclude_none=True) sempre carregava a chave, e a guarda da RN11a — que olha se o campo veio no corpo — recusava o create inteiro para o SaaS admin, mesmo quando ele não mandava o campo. Contradizia a própria RN11a, que recusa o campo, não a operação. A suíte ficou verde porque o caminho é hoje inalcançável: o ADMIN_GLOBAL semeado nasce sem tenant, e _get_tenant recusa antes. O campo passou a ser bool | None, o construtor a usar exclude_none=True, e dois testes novos cobrem o create — um com ADMIN_GLOBAL com tenant, que é o único jeito de alcançar a guarda. Red-green conferido: sem a chamada da guarda, o teste falha.

Fecharam junto os outros achados: o Swagger dos dois routers passou a documentar o 403 novo, a omissão por perfil e os três campos com suas faixas; a lista ganhou teste próprio (e a docstring do service passou a avisar que parametrizar o PaginatedResponse reinstala o campo excluído — red-green conferido também); e duas docstrings de teste que afirmavam mais do que o teste provava foram corrigidas, uma delas dizendo agora que o 403 vem do porteiro do router, não da guarda de service.

Execução ao fim da fase: 13 testes no arquivo novo e 100 verdes no conjunto que a fase podia alcançar. Um vermelho — test_partner_agency_status_filter.py::test_alcanca_quem_esta_fora_da_primeira_pagina —, que é uma das duas intermitentes conhecidas do CLAUDE.md: rodada isolada cinco vezes deu 4 verdes e 1 vermelha, a proporção de cara-ou-coroa que o próprio documento descreve, e o arquivo de teste não foi tocado por esta branch (git diff main vazio).

Fase 6 — o apps/e2e

Metade do trabalho foi consertar o que esta spec quebrou no spec da 0048, e isso é o resultado mais útil da fase. A 0054 não acrescenta uma tela: acrescenta dimensões à que existe, e cinco suposições do arquivo anterior deixaram de valer de uma vez —

  1. A aba do mês recorta por competência, sempre. Era a mudança de maior alcance e a menos visível: nenhum seletor novo, nenhum campo novo, e todo teste que contava linhas passou a contar outra coisa. O caso da RN2 contava as doze parcelas de um calendário recém-nascido numa tela que agora mostra uma. Foi reescrito para visitar o primeiro e o último mês — o primeiro prova que a assinatura gerou o calendário, o último prova que a lista alcança o fim dele, que é o que uma tela mostrando uma fatia falharia.
  2. billings-status-filter virou billings-situacao-filter (RN24a), e Cancelado virou Cancelada.
  3. O sentinela sem-competencia saiu (RN5). O teste que existia para ele afirmava o contrário do que a spec passou a valer, e foi invertido: a avulsa agora tem mês — o do próprio vencimento — e é isso que se prende.
  4. O link da Central passou a ser ?aba=atrasados. A leitura de ?status= saiu da tela (9.7): ela caía na competência corrente e escondia justamente o atraso mais antigo, que é o que a pessoa foi ver.
  5. O filtro de Competência voltou ao mobile (7.2), e o teste de 375px afirmava a ausência dele. A asserção virou do avesso — e o mesmo filtro é hoje o que leva o teste até o cartão que ele mede.

A parceira que recolhe virou registro próprio, em vez de mexer em TenantSettings. O canal sai de duas pontas (RN9): o meio de cobrança da garantidora, que é do tenant e portanto compartilhado com a suíte inteira, e o collects_monthly da parceira, que é por registro. O plano da fase dizia que o helper aceitaria as duas; só a segunda entrou, e deliberadamente — uma imobiliária própria com o recolhimento ligado prova AGENCY_PIX sem que nenhum outro spec veja diferença, enquanto trocar collection_method mudaria o mundo debaixo de quem estivesse rodando em paralelo. A combinação com GUARANTOR_PIX fica com os testes de unidade da API, que cobrem a matriz inteira; o e2e prova a que atravessa duas entidades. Para o que não tem como escapar de tocar na configuração do tenant — o encargo, que exige juros e carência parametrizados —, o arquivo ganhou comParametros(), que lê, aplica e devolve como estava no finally, que é a regra do CLAUDE.md da raiz transformada em função para não depender de ninguém lembrar.

O que ficou só no e2e, e por quê: o painel de pagamento em 375px. Total preso no rodapé com o corpo rolado até o fim, confirmação à vista e scrollWidth - clientWidth == 0 — o jsdom não tem layout, então esses três não se medem no make test-web (spec 0047, RN2). É o único lugar do produto que mexe em dinheiro, e um total que rola para fora obriga a pessoa a subir para conferir quanto está confirmando.

A primeira execução deu 16 de 21, e as cinco falhas foram todas de teste — nenhuma do produto. Vale registrar porque duas delas eram eu supondo coisa que a spec já tinha decidido contra:

  1. "Marcar inadimplente" não aparece mais na parcela da garantidora. O teste da 0048 clicava nele em qualquer pendente; agora ele sobrevive só na Aguardando a imobiliária (7.1), porque a rotina das 7h carimba o resto e repetir o carimbo à mão não decide nada. O teste foi reescrito para o único caso em que uma pessoa precisa decidir — e ganhou, de lambuja, a asserção de que o botão some depois.
  2. A fila é por vencimento, não por status. Eu carimbava OVERDUE numa parcela que vence no mês que vem e esperava vê-la na aba. Ela não aparece, e não deveria: a fila responde "o que venceu e não foi pago" (RN25). Foi o teste que estava errado sobre a regra, não a regra sobre o teste.
  3. FilterSelect e FilterPill rendem papéis diferentes — option no desktop, menuitem em 375px. O helper sabia de um só, e os dois testes de celular caíam num erro que parecia do produto ("a opção não existe"). O helper passou a aceitar os dois.
  4. A aba do comprovante é opaca ao Playwright. Ela é aberta com noopener, e o que se lê da URL dela não é o blob nem about:blank — voltou ":". A asserção mudou de alvo: o que importa, e o que quebraria se alguém trocasse o blob por um href direto (que é o erro natural aqui, e não funciona porque a API exige o token), é a chamada ao /download com o comprovante certo.

A quinta foi o login estourando 20s — a armadilha de ambiente que o CLAUDE.md do apps/e2e já descreve. Resultado final: 21 de 21, com um trabalhador.

Revisão da branch inteira

O portão final achou um Critical que cinco revisões por fase não tinham achado, e é exatamente o tipo de defeito que só se vê olhando os dois apps juntos: a API e o apps/web implementaram o filtro de Tipo com semânticas diferentes.

A Parte II 7.1 decide que ele trabalha sobre o que a parcela contém — filtrar por Adesão alcança a entrada composta, e por Fiança também, porque ela contém os dois; e diz, na mesma frase, o motivo: tratar SETUP_AND_GUARANTEE como um quarto valor faria "mostre-me o setup do mês" esconder justamente as parcelas onde há mais setup. O apps/web cumpriu o contrato (três opções, com a regra repetida no comentário do vocabulario.ts); a API comparou por igualdade nas duas consultas.

O efeito é mudo, e é o defeito que a spec inteira existe para acabar. Numa competência com a entrada de R$ 714,04, o resumo mostra a linha Adesão · Boleto com R$ 300,00; um clique em Tipo = Adesão devolve zero linhas e zera os indicadores — a linha que existia um clique antes some, sem erro nenhum. E a exportação filtrada passa a contradizer o resumo não filtrado: duas somas da mesma coisa discordando.

Não havia teste nenhum do filtro fee_type — nem na API, nem no web além de um caso que só afirma que o parâmetro viaja na query. A regra podia sumir inteira sem nada ficar vermelho, e foi por isso que atravessou cinco rodadas. A condição virou condicao_de_tipo em app/domain/composicao_de_cobranca.py, ao lado da invariante da composição e pelo mesmo motivo que condicao_de_situacao mora junto da derivação: a lista, o resumo e a exportação filtram pelo mesmo objeto, e escrita três vezes ela diverge na primeira correção.

O teste novo corrigiu uma expectativa minha, e vale registrar. Eu esperava que summary?fee_type=SETUP somasse só R$ 300,00. Ele soma R$ 714,04, e está certo: o filtro escolhe quais cobranças entram, e a RN26 decompõe cada uma pelo que ela contém. Descartar a metade de fiança daria um total debaixo de uma lista que mostra uma parcela de R$ 714,04 — o resumo contradizendo a lista que ele encima. O filtro promete mudar os números, não esconder metade de uma parcela que ele mesmo trouxe.

O Important era a porta de entrada mais frouxa que a de saída. reference_month era validado na leitura (RN5, 422 INVALID_REFERENCE_MONTH) e não na escrita: uma avulsa lançada com 05/2026 era criada com 201. A partir dali, GET /billings/reference-months — que devolve os valores distintos da coluna — passava a oferecer aquele texto ao seletor de Competência, e escolhê-lo caía no 422 do filtro: a tela recusando o valor que ela mesma ofereceu, com a cobrança sem aparecer em competência nenhuma. Os dois lados passaram a usar a mesma régua, e o campo virou type="month" — que é a metade do conserto que impede o texto errado de nascer.

Fecharam junto os cinco Minor, e dois deles mudaram de conserto no caminho:

  • O filtro de tenant que quebraria o SaaS admin. receipts_by_billing é o único método do repositório de documentos sem tenant_id no where, e a revisão pediu que entrasse. Pôr um tenant_id único ali quebraria o caso que existe: o ADMIN_GLOBAL lê a lista agregada de todos os tenants, e a página que chega ali mistura garantidoras de propósito. O conserto foi tornar a decisão explícita na docstring, com o porquê — o que protege é o argumento, que já vem escopado pelo repositório de cobrança.
  • "Não estão parametrizados" virou "não cobra multa nem juros". A tela não tem como distinguir quem ainda não configurou de quem configurou zero, e multa de 0% é política, não lacuna. A frase anterior afirmava um descuido que podia não ter havido.

E um Enum duplicado sem create_type=False (sintoma longe da causa: create_all contra PostgreSQL morreria com type already exists), um ternário de ordenação com os dois ramos idênticos, e os testes que faltavam nos dois achados maiores — com red-green nos dois.

A segunda rodada aprovou — nota 8,5, nenhum Critical —, e o Important dela era irmão do da primeira: uma regra de ordenação que a Parte II fixa e a API implementou ao contrário. As linhas do resumo eram ordenadas pelo nome do enum do canal, e em texto "AGENCY_PIX" < "GUARANTOR_BOLETO": o repasse da parceira subia para o topo de cada tipo, quando a 7.1 manda os canais da garantidora antes. Não é estética — a garantidora fecha o mês somando o que entrou na conta dela e depois conferindo o que está com terceiro. E o alcance era dos dois consumidores, porque a API é quem decide a ordem: a tabela da tela e a aba Resumo da planilha só iteram rows. Nenhum teste prendia a ordem por canal dentro do tipo; agora prende, com red-green.

Os cinco Minor foram fechados, e dois deles ensinaram algo:

  • Um comentário meu afirmava uma proteção que o código não tinha. Eu havia escrito create_type=False no sa.Enum do payment_channel e na migration, explicando que era o que impedia o type already exists. O revisor mediu: o kwarg é do postgresql.ENUM, e o sa.Enum genérico o descarta em silêncio (conferido em SQLAlchemy 2.0.49 — o dialect_impl do PostgreSQL volta com create_type=True). Isto é pior que a ausência: quem esbarrasse no erro acreditaria no comentário em vez de medir. Os dois passaram a dizer o que é verdade — o que de fato protege é o add_column do Alembic não emitir CREATE TYPE — e a dizer qual é o caminho se algum dia a garantia explícita for necessária.
  • A CA36 descrevia um estado inalcançável. Ela dizia "com juros e carência não configurados", que é o estado do dia seguinte de toda garantidora — mas late_fee_percent é não anulável, com default 2,00, então ali a multa existe e o encargo corre. O critério foi reescrito para o que a tela de fato mostra: quando nada é cobrado. Junto, o aviso mudou de "não estão parametrizados" para "não cobra multa nem juros", porque a tela não distingue quem não configurou de quem configurou zero — e zero é política, não lacuna.

Fecharam também: o pago da linha Avulsas, que podia ler outro balde sem nada ficar vermelho (o teste do total soma linhas e total juntos, então os dois se moviam juntos); duas leituras de "hoje" por requisição — o serviço fixava a data e o repositório a lia de novo, o que numa requisição atravessando a meia-noite filtrava com uma data e derivava a situação com a outra; e o BILLING_STATUS que sobreviveu exportado sem consumidor, competindo com o BILLING_SITUATION que o substituiu.

Fechamento. Com as correções das duas rodadas dentro, o dev-remote foi reconstruído (api, web e worker) e o spec de Cobranças rodou contra ele: 21 de 21. É a execução que vale, porque as imagens embutem o código — a anterior media a versão de antes dos dois achados.

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.

As linhas desta tabela são do repositório de antes da implementação, e não foram reapontadas — de propósito. Cada afirmação aqui é "isto não existe hoje" ou "isto está na linha N", conferida antes de a branch escrever uma linha de código; reapontá-la agora seria fazer a tabela afirmar sobre um repositório que ela nunca viu, e apagaria justamente o retrato que ela existe para guardar. Ela serviu aos portões das Partes I a III, que estão fechados, e não vale para a revisão da implementação — o revisor do código confere o código. As citações do corpo da spec, essas sim, foram reapontadas a cada fase; o §18 registra por quê. | Afirmação | Onde na spec | Conferida em | |---|---|---| | Billing não tem campo de tipo nem de canal — billing.py:104-130 | §1, bullet Canal | rodada 1 | | Billing.reference_month está em billing.py:121 | §1, bullet Parcela | rodada 1 | | PartnerAgency não tem nenhum campo financeiro — partner_agency.py:23-43 | §1, bullet Canal | rodada 1 | | mark_overdue é a única escrita de OVERDUE, e é manual — billing_service.py:156-173 | §1, §3 passo 6 | rodada 3 | | Não há juros nem carência de mora no modelo; claim_grace_period_days é de sinistro — tenant_settings.py:40-80, :56 | §1 | rodada 3 | | A lista conta registros mas não soma dinheiro — billings/index.tsx:256-257,284-285 | §1 | rodada 3 | | A lista filtra a competência por reference_month — apps/web/src/pages/billings/index.tsx:130 | §1 | rodada 3 | | A Central recorta o mês por janela de due_date — operations_center_service.py:560-565 | §1, §3 passo 7 | rodada 3 | | A soma de atraso e a inadimplência da home vêm de operations_center_service.py:569,583 | §4, §5 | rodada 3 | | Não há campo de canal em tenant_settings.py nem em partner_agency.py | §1, bullet Canal | rodada 3 | | A RN2 da 0048 está em docs/specifications/0048-gestao-de-cobrancas/specification.md:476 | §3 passo 1 | rodada 3 | | O worker veio da 0050 e o job diário da 0052 — worker.py:59,99-102 | §3 o que já se tentou | rodada 3 | | A Central soma o atraso por status == OVERDUE — operations_center_service.py:576-579 | §1, §3 o que já se tentou | rodada 4 | | A tela dá baixa e marca inadimplente — billings/index.tsx:170,522,592 | §3 passo 4 | rodada 4 | | Não há campo de parcela em Billing nem em Guarantee | §1, bullet Parcela | rodada 4 | | BillingOrigin distingue CALENDAR de MANUAL — billing.py:51,125 | §1, nota sobre "canal" | rodada 4 | | emAtraso calcula o atraso no front — billings/index.tsx:78-80 | §1, §3 | rodada 1 | | As colunas da lista são as nove de billings/index.tsx:434-442 | §1, §3 passo 2 | rodada 1 | | initial_fee e first_month_upfront são colunas da garantia — guarantee.py:205-206 | §1, bullet Tipo | rodada 1 | | O detalhe mostra "Taxa de setup" e "1ª mensalidade" — GuaranteeSections.tsx:120-121 | §1, bullet Tipo | rodada 1 | | first_month_upfront é zero com a flag desligada — pricing.py:76-77 | §1, bullet Tipo | rodada 1 | | As mensais começam no mês 1 sem a flag — guarantee_service.py:735 | §1, §3 passo 1 | rodada 1 | | charge_first_month_upfront é parâmetro do tenant — tenant_settings.py:73 | §1, bullet Tipo | rodada 1 | | A flag vira snapshot na garantia — guarantee.py:203 | §1, bullet Tipo | rodada 1 | | ChannelType é EMAIL/WHATSAPP — channel_integration.py:9-12 | §1, nota sobre "canal" | rodada 1 | | ADMIN_TENANT é o único papel de sistema com billings:update — rbac_seed.py:280-281, subtração em :228 | §2 | rodada 1 | | ANALYST/OPERATOR/VIEWER têm billings:list_all — rbac_seed.py:329,359,379 | §2 | rodada 1 | | AGENCY_USER não recebe billings:list_all — rbac_seed.py:422-427 | §2, §5 | rodada 1 | | Os alertas da Central de Operações leem o status gravado — operations_center_service.py:140-141,153,169,244,369 | §3 passo 6 | rodada 1 | | O calendário nasce na assinatura — guarantee_service.py:705-747 | §3 passo 1 | rodada 2 | | A cobrança de entrada usa total_upfront — guarantee_service.py:723-732 | §1, bullet Tipo | rodada 2 | | A Central soma previsão × realizado por mês — operations_center_service.py:541-586 | §1, §3 o que já se tentou | rodada 2 | | get_financial_summary expõe esse resumo — operations_center_service.py:333-352 | §1 | rodada 2 | | A home pinta os chips do resumo — SummaryCards.tsx:185-186 | §1 | rodada 2 | | A ação rápida de atraso traz contagem e link — operations_center_service.py:382-388 | §3 o que já se tentou | rodada 2 | | A lista consome status da URL — apps/web/src/pages/billings/index.tsx:99 | §3 o que já se tentou | rodada 2 | | A Central é a página inicial do tenant — App.tsx:160 | §1 | rodada 2 | | Guarantee não tem campo de meio de pagamento — guarantee.py:131-212 | §1, bullet Canal | rodada 2 | | A 0048 excluiu o agendador por ele não existir — docs/specifications/0048-gestao-de-cobrancas/specification.md:182-185 | §3 o que já se tentou | rodada 2 | | ADMIN_GLOBAL não escreve cobrança (RN9) — docs/specifications/0048-gestao-de-cobrancas/specification.md:537 | §2 | rodada 2 | | A RN19a está em docs/specifications/0038-emissao-e-assinatura-da-garantia/specification.md:1130 | §2, §5 | rodada 2 | | A 0048 deixou o fechamento por imobiliária fora — docs/specifications/0048-gestao-de-cobrancas/specification.md:173-178 | §3 o que já se tentou | rodada 1 | | A 0048 deixou renegociação fora — docs/specifications/0048-gestao-de-cobrancas/specification.md:191-193 | §5 | rodada 1 | | O ROADMAP.md:139-142 diz que Billing não é boleto emitido | §5 | rodada 1 | | O item E2 do roadmap está em ROADMAP.md:237-242 | §5 | rodada 1 |

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.