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.
- A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
- A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
- A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
- A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.
Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.
Parte I — O problema¶
Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.
1. Dor¶
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_feeefirst_month_upfrontsã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 comamount = 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é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, enumBillingOriginem: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. - Parcelan/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 únicograceali,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 seguePENDING, 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 combillings: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,OPERATOReVIEWERleem 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_GLOBALlê 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_USERnão alcança a tela agregada: a permissãobillings:list_allnunca 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.
- 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 comtotal_upfrontno primeiro vencimento e uma demonthly_valuepor 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). - 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). - 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. - 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. - 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
PENDINGna 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). - 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. - 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 ostatusgravado continuaPENDING— 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 emapps/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 workerarqnasceu com a spec 0050, e a 0052 acrescentou a ele um job diário agendado porcronpara 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 é (
ndeN). 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
OVERDUEsozinha — 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_USERmarque 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.
Billingcontinua 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.mdfica 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 doCLAUDE.mdentra 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:
- O operador abre Cobranças. A tela já está na competência corrente.
- 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.
- 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.
- Na lista, cada parcela já diz de que tipo é, que parcela é (
5/12) e por onde o dinheiro entra. Ele não reclassifica nada. - 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.
- 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.
- 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:
- 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:
- 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.
- 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.
- 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.
- 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 —
Billingganha tipo. Colunafee_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) eMISC(a avulsa, RN7). O enum e a coluna seguem o molde deBillingOrigin(apps/api/app/models/billing.py:51-64e:125-130): classestr, enum.Enumcom docstring por membro,name=explícito noEnumda coluna,default=em Python eserver_default=em SQL. - RN2 — A entrada guarda os dois valores discriminados. Colunas
setup_amounteguarantee_amount,Numeric(12, 2),nullable=True. EmSETUP_AND_GUARANTEEas duas são preenchidas; emSETUPsó a primeira; emGUARANTEEsó a segunda; emMISCnenhuma. Invariante, verificada no service em toda escrita: quandofee_typenã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 —
Billingganha canal. Colunachannel,Enum(BillingChannel, name="billing_channel"),nullable=False, com os três valores da Parte II 7.B:GUARANTOR_BOLETO,GUARANTOR_PIXeAGENCY_PIX. O nomechannelé escolhido com a colisão à vista (Parte I, seção 1):ChannelTypejá existe emapps/api/app/models/channel_integration.py:9-12para notificação, eBilling.origin(billing.py:51) já ocupou "origem". A desambiguação é o prefixo da tabela —BillingChannelcontraChannelType— e o fato de as duas nunca aparecerem no mesmo módulo. - RN4 —
Billingganha a parcela, e ela conta mensalidades. Colunasinstallment_numbereinstallment_total,Integer,nullable=True.installment_totalé o número de mensalidades do plano — os meses de vigência —, einstallment_numberé a posição da mensalidade que aquela cobrança carrega. Assim5/12significa 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")comBOLETOePIX,nullable=False,server_default="BOLETO". É o meio pelo qual a garantidora cobra quando é ela quem cobra. Nasce comBOLETOe não admite ficar vazio: dele depende o canal de toda parcela gerada (Parte II, 7.4). Entra nas três classes deapps/api/app/schemas/tenant_settings.py—Create(:46-164),Update(:167-239) eResponse(:242-312) — no molde decharge_first_month_upfront(:120-123,:183,:257), e nojson_schema_extradoCreate(: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 deperiodo.pyeguarantee_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}/billingseGET /guarantees/{gid}/billings/{bid}passam a devolverlate_fee_projectedeinterest_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 —
BillingPayRequestcresce, e tudo que ele traz é persistido. Aoamount_paidde 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) enotes(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 comexigir_permissao(apps/api/app/core/dependencies.py:83-102), porque depende do tipo da entidade dona e não se resolve noDependsdo 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_escritaretorna cedo para qualquerentity_typeque não sejaguarantee(apps/api/app/services/document_service.py:264), antes doPLATFORM_ADMIN_CANNOT_AMENDde: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 oADMIN_GLOBALatravessaria e tentaria gravar umDocumentcomtenant_idnulo, produzindo erro de integridade em vez de 403. A guarda nova recusa com 403PLATFORM_ADMIN_CANNOT_ATTACH_BILLING_RECEIPT, no mesmo ponto e pelo mesmo motivo queBillingService._garantir_que_nao_e_o_saas_adminrecusa a baixa (billing_service.py:102-121). A subtração debillings:updateno mapa (spec 0048, RN9) é reforço; quem recusa é esta guarda, porque a fixture de teste doADMIN_GLOBALconcede todas as permissões sem consultar o mapa. - A imobiliária é recusada.
_garantir_que_a_imobiliaria_pode_escreverramifica em garantia, análise, inquilino e imóvel e cai numelse: 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 parabilling, que recusa com 403AGENCY_CANNOT_WRITE_BILLING_DOCUMENTquando quem chama tempartner_agency_idpreenchido. 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 nopayloada 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
cronno workerarq(apps/api/app/worker.py:149-158), às 7h no fuso local — uma hora depois dodaily_credit_finance_jobque já existe (:102), para os dois não disputarem banco no mesmo minuto —, declarado comtimeout=600erun_at_startup=False, como o vizinho. Ele marcaOVERDUEtoda cobrança que: estáPENDING, temdue_dateanterior a hoje, não está removida (deleted_at is NULL) e temchannelemGUARANTOR_BOLETOouGUARANTOR_PIX.AGENCY_PIXfica 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 defee_type×channelpresente na competência, mais o total. Por linha: cobrado, pago, abatido, a receber, encargo recebido e parcelas. - A entrada é decomposta:
setup_amountsoma na linha deSETUP,guarantee_amountna deGUARANTEE, 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. MISCforma a linha Avulsas, sempre a última antes do total.- Removidas e canceladas nunca entram —
deleted_at is NULLestatus != CANCELLED. A exclusão da removida é da Parte II, 7.1; a da cancelada é o que faz oprevistobater 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_GLOBALcom 403PLATFORM_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 únicoindicatorso 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-competenciado 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 com422. E, do lado da API, o ramo que a implementava emapps/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 deGET /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/summaryeGET /billings/exportreusam o caminho deBillingService.list_all(apps/api/app/services/billing_service.py:384-428), que passatenant_scope(current_user)ao repositório;BillingRepositoryaplicaif 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_billingjá 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_iddo upload de comprovante, que é umbilling_id. Ele é resolvido no ramo explícito debillingqueDocumentService._validate_entity(apps/api/app/services/document_service.py:749-755) ganha antes do caminho genérico, chamandoBillingRepository.get_in_scope(billing_id, current_user)— a consulta que juntaGuaranteee aplica tenant e imobiliária no mesmowhere(RN20). Não passa porresolve_in_scopenem 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 tempartner_agency_idpró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_GLOBALtentando exportar ou pagar, e o perfil sembillings:updatetentando registrar pagamento.
10.4 Isolamento por imobiliária¶
- Nada muda no que o
AGENCY_USERalcanç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 queBillingResponse, 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.
Billingnão tempartner_agency_id; o ramo novo deDocumentRepository._owned_by_agency(apps/api/app/repositories/document_repo.py:17-53) alcançaGuarantee.partner_agency_idpela garantia da cobrança — o mesmo vínculo quelist_cross_guaranteejá 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 exigerequire_system_role("ADMIN_TENANT")(apps/api/app/api/v1/routers/documents.py:122), então nenhuma imobiliária a alcança e oagency_scopededocument_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=billinge umentity_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ótuloAWAITING_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_scopedevolveNone(apps/api/app/services/scoping.py:42-48) e oBaseRepositorynão filtra nada (apps/api/app/repositories/base.py:56-57). Consequência, endpoint a endpoint desta spec: GET /billingseGET /billings/{id}— devolvem todos os tenants. É continuidade deliberada da spec 0048, não decisão nova, e o motivo está registrado emrbac_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/summaryeGET /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 .../chargeePOST .../pay— os dois recusam antes de resolver, com403: as duas rotas exigembillings:update, subtraída dele emrbac_seed.py:241, e a guarda_garantir_que_nao_e_o_saas_admin(billing_service.py:102-121) fecha opaypor desenho, comPLATFORM_ADMIN_CANNOT_SETTLE_BILLING. Ainda que a permissão lhe fosse concedida por engano,_get_billingpassacurrent_user.tenant_idcru (billing_service.py:790-792), que éNonepara 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_adminguarda também ocreate(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_idde fora. Os filtros novos sãofee_type,channel,overdue_onlye 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 deGET /billingscomoverdue_only(RN25), que exigebillings: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çasorigin=CALENDARpor garantia: a de menordue_dateé a entrada e recebeSETUP_AND_GUARANTEE(ouSETUP, seguarantees.charge_first_month_upfrontfor falso), comsetup_amount = guarantees.initial_feeeguarantee_amount = guarantees.first_month_upfront; as demais recebemGUARANTEEcomguarantee_amount = amount.origin=MANUALrecebeMISC. Ochannelde toda linha existente sai deGUARANTOR_BOLETO, porquecollection_methodnasce emBOLETOe nenhuma parceira nasce recolhendo.installment_number/installment_totalsão preenchidos pela ordem dedue_datedentro 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 emBillingAgencyResponse(RN13, seção 10.4)GET /api/v1/billings— ganhafee_type,channel,situationeoverdue_only; perde o filtrostatuse o sentinelasem-competencia(RN5, RN24a); a resposta ganha os campos novos e os dois projetados (RN13)GET /api/v1/billings/{id}— a resposta ganha os mesmos camposGET /api/v1/billings/summary— novoGET /api/v1/billings/export— novoGET /api/v1/guarantees/{gid}/billings/{bid}/charge— novoPOST /api/v1/guarantees/{gid}/billings/{bid}/pay— corpo maior (RN15)POST /api/v1/documents— aceitaentity_type=billingGET/PATCH /api/v1/settings— três camposPOST /api/v1/onboarding/partner-agenciesePATCH /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—creategrava a classificação da avulsa (RN7);paycresce (RN15–RN19, RN22);list_allganha os filtros novos e os campos projetados (RN13); nascesummary(RN26–RN27),export(RN29) echarge(RN14). Toda consulta continua passando porBillingRepository, comtenant_scopeeagency_scope— nenhumselectnovo 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 dasTenantSettings, que é o que permite a leitura projetar linha a linha com os parâmetros do dono de cada uma (RN13). No mesmo lugar deperiodo.pyeguarantee_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 demax/min— e a frase encosta, no mesmo formulário, em valores que oapps/webformata comfmtBRL.R$ 1022.00ao lado deR$ 1.022,00lê-se como defeito. A implementação veio deservices/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_TYPESdeixa de derivar de_ENTITY_REPOSe ganhabilling; okwargsdo 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 oupload, no casobilling, 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_mape_owned_by_agencyganham o ramo que alcançaGuarantee.partner_agency_idpela 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_idsai porouterjoin— em_base_cross_guarantee(apps/api/app/repositories/billing_repo.py:91-104) para a lista agregada, comorenter_nameepartner_agency_namejá saem, e nas consultas por garantia (list_by_guarantee,:17-40, eget_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_financialtroca o recorte (RN28); e os três pontos que emitemaction_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— declarabillings:exportem_PERMISSIONSe a concede aADMIN_TENANT,ANALYST,OPERATOReVIEWERem_ROLE_PERMISSIONS; não aADMIN_GLOBALnem aAGENCY_USER(seção 10.2). A subtração doADMIN_GLOBALganha comentário no mesmo formato dos três que já estão lá (:206-219).app/worker.py— segundo jobcron(RN23), acrescentado à lista decron_jobs(:102). Não entra emfunctions— essa lista é só para job enfileirado.app/schemas/billing.py— os campos novos naResponsee os da baixa noBillingPayRequest.BillingCreatenão os recebe:fee_type,channele a parcela são derivados pelo service (RN7), e ocreatedesempacota 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 escolherAGENCY_PIX, tirando aquela cobrança da rotina de atraso (RN23) e doa_receber_garantidora(RN27).reference_monthcontinua 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.pyepartner_agency.py— os campos novos nas três classes de cada (Create/Update/Response), incluindo ojson_schema_extra.app/main.py— oTAGS_METADATAda tag Cobranças passa a mencionar resumo e exportação; e adescriptiondo parâmetroreference_monthemapp/api/v1/routers/billing.py:48,60perde 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 nasceMISC; 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_GLOBALrecebe 403 em/billings/exporte continua recebendo 403 nopay;AGENCY_USERrecebe 403 em/billings/summarye em/billings/export, e não vêcollects_monthlyna resposta da própria imobiliária;ANALYSTrecebe 403 ao pagar e 200 ao exportar; cobrança de outro tenant responde 404 nochargee 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 URLsrc/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 abasrc/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 contextosrc/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 comprovantesrc/pages/settings/index.tsx— três campos: juros no painel Taxas, carência e meio de cobrança em Cobrança e sinistrosrc/pages/agencies/form.tsx,index.tsx,show.tsx,api.ts— o campocollects_monthly, a coluna Mensalidades e o selosrc/pages/billings/api.ts— resumo, exportação e conta abertasrc/providers/api-fetch.ts—apiReqnão serve para baixar arquivo: ele forçaContent-Type: application/jsoneJSON.stringify. A exportação usa o caminho do download de documento (src/shared/api/documents.ts:163-187:fetchcomAuthorizationmanual,res.blob(),URL.createObjectURL,<a download>,revokeObjectURL)src/contexts/nav-badges/index.tsx— campo novo,overdueBillings. Não se reaproveitaoverdueGuarantees: 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 em0hoje (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 resolvesrc/components/navigation/nav-groups.tsx— o item Cobranças (:195-204), que hoje não tem badge nenhum, ganhabadgeCount: badges.overdueBillingsebadgeColor: "error"src/pages/operations-center/index.tsxesrc/pages/billings/index.tsx— passam a escreveroverdueBillingspela 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 contextosrc/shared/utils/dateUtils.ts— duas funções novas e compartilhadas,hojeLocalISO()eformatarDataISO(), nascidas de defeitos de fuso desta fase: "hoje" portoISOString()(UTC, que às 21h de Brasília já virou amanhã) enew 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 emSignatureDialog.tsx, dois arquivos ao lado, sem que ninguém achassevite.config.ts—TZ=America/Sao_Paulonotest.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 mesmohoje_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¶
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 = BOLETOecharge_first_month_upfrontligado, quando uma garantia de 12 meses é assinada, então nascem 12 cobranças: a primeira comfee_type = SETUP_AND_GUARANTEE,setup_amount = initial_fee,guarantee_amount = first_month_upfront,installment_number = 1,installment_total = 12; e 11 comfee_type = GUARANTEE,installment_numberde 2 a 12. A última lê12/12(RN1, RN2, RN4, RN6). - [ ] CA2. Dada a mesma garantia com
charge_first_month_upfrontdesligado, então nascem 13 cobranças: a de setup comfee_type = SETUP,guarantee_amountnulo e parcela nula; e 12 mensais numeradas de1/12a12/12. Nenhuma parcela repete número (RN2, RN4, RN6). - [ ] CA3. Dada uma parceira com
collects_monthlyverdadeiro, quando a garantia dela é assinada, então a entrada nasceGUARANTOR_BOLETOe todas as mensais nascemAGENCY_PIX; comcollects_monthlyfalso, todas nascemGUARANTOR_BOLETO(RN3, RN9, RN11). - [ ] CA4. Dada uma garantidora com
collection_method = PIX, então a entrada nasceGUARANTOR_PIX(RN8, RN9). - [ ] CA5. Dada uma cobrança lançada à mão sem
reference_monthno corpo, então ela nascefee_type = MISC, canal do meio da garantidora, parcela nula ereference_monthigual ao mês dodue_dateinformado (RN7). - [ ] CA6. Depois da migration, nenhuma cobrança tem
reference_monthnulo, e o filtro de competência não aceita mais o valorsem-competencia— enviá-lo responde422em vez de listar (RN5). - [ ] CA7. Para toda cobrança com
fee_typediferente deMISC,coalesce(setup_amount,0) + coalesce(guarantee_amount,0)é igual aamount— 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 /billingsdevolvelate_fee_projectedeinterest_projectedcalculados 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 responde422 CHARGE_DATE_IN_FUTUREpara 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_onno futuro responde422 PAYMENT_DATE_IN_FUTURE;paid_onanterior ao vencimento é aceito e produz encargo zero (RN17). - [x] CA14.
waive_late_feeverdadeiro sem motivo responde422 WAIVER_REASON_REQUIRED; com motivo echarge_paiddiferente de zero, responde422 PAYMENT_ALLOCATION_MISMATCH(RN18). - [x] CA15. Pagando R$ 900 de um principal de R$ 1.000, a cobrança fica
PAIDcomdiscount_amount = 100.00; pagando R$ 1.200 do mesmo total devido, responde422 PAYMENT_EXCEEDS_DUEcom o teto no corpo (RN19). - [x] CA16. O registro do pagamento grava auditoria
PAIDcujopayloadtraz data, canal de entrada, a distribuição, o abono e o motivo (RN22). - [x] CA17. Um comprovante enviado com
entity_type=billingfica acessível peloreceipt_document_idda 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
PENDINGvencida de canalGUARANTOR_BOLETOouGUARANTOR_PIXpassa aOVERDUE, e nenhuma de canalAGENCY_PIXmuda de status (RN23). - [x] CA19. Uma cobrança
PENDING, vencida, de canalAGENCY_PIXtemsituation = AWAITING_AGENCY; marcada à mão, passa aOVERDUEnos dois campos. O enumBillingStatuscontinua com os mesmos quatro valores (RN24). - [x] CA20.
GET /billings?overdue_only=truedevolve as vencidas e não pagas de todas as competências, ordenadas do mais vencido para o menos, e ignorareference_monthquando 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
SETUPe R$ 414,04 na deGUARANTEE, e a parcela é contada uma vez, na linha deSETUP; 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_repassarfecha,abatidoé R$ 100 — não R$ 120 — e o encargo abonado aparece só comoencargo_recebidomenor (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/exportna competência devolve.xlsxcom 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
previstodo mês corrente em/operations-center/financial-summaryé igual aoprevistode/billings/summaryda mesma competência (RN5, RN28).
Autorização — os negativos, um por papel afetado
- [x] CA27.
ADMIN_GLOBALrecebe 200 emGET /billings, com registros de mais de um tenant, e continua recebendo 403 emPOST .../pay(seção 10.1, 10.5). Os agregados são a CA38. - [ ] CA28.
AGENCY_USERrecebe 403 emGET /billings/summarye emGET /billings/export; eGET /partner-agencies/{a própria}não trazcollects_monthlyno corpo (seção 10.1, 10.4). - [x] CA29.
ANALYST,OPERATOReVIEWERrecebem 200 em/billings/summarye em/billings/export, e 403 emPOST .../pay(seção 10.1, 10.2). - [x] CA30.
ADMIN_TENANTda Garantidora A recebe 404 — não 403 — ao pedirGET .../billings/{id}/chargede uma cobrança da Garantidora B, e 404 ao anexar comprovante nela; nada é gravado (seção 10.3). - [x] CA31.
AGENCY_USERda 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 é deADMIN_TENANT, e o comprovante entra na contagem do tenant como qualquer outro documento (seção 10.4). -
[ ] CA32. Um usuário sem
billings:list_allque digite/billingsna 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_GLOBALrecebe 403PLATFORM_ADMIN_CANNOT_AGGREGATE_BILLINGSemGET /billings/summarye emGET /billings/export, e continua recebendo 200 emGET /billingscom registros de mais de um tenant (RN26a). - [x] CA39.
ADMIN_GLOBALanexando comprovante numa cobrança de qualquer tenant recebe 403PLATFORM_ADMIN_CANNOT_ATTACH_BILLING_RECEIPT— e não um erro de integridade, que é o que o caminho de hoje produziria (RN20a). - [x] CA40.
AGENCY_USERda imobiliária dona da garantia lê o comprovante da cobrança dela e recebe 403AGENCY_CANNOT_WRITE_BILLING_DOCUMENTao tentar anexar um (RN20a, seção 10.4). - [x] CA41.
ANALYSTeVIEWERrecebem 403 ao anexar comprovante — neles o porteiro do router já basta, por não teremdocuments:create(apps/api/app/db/rbac_seed.py:329,379). OOPERATORé o caso que a RN20a existe para cobrir: ele temdocuments:create,documents:updateedocuments:delete(:361), e recebe 403 ao anexar, ao editar e ao remover comprovante, por não terbillings:update(:359). - [ ] CA42.
ADMIN_GLOBALenviandocollects_monthlyemPATCH /partner-agencies/{id}recebe 403PLATFORM_ADMIN_CANNOT_SET_COLLECTION, e o valor gravado não muda (RN11a). - [x] CA43.
AGENCY_USERda Imobiliária A que envie oidde uma cobrança da Imobiliária B no upload de comprovante recebe 404 — não 403 —, e nada é gravado; oBillingRepository.get_in_scopeaplica os dois escopos na mesma consulta (RN20, seção 10.3). - [x] CA44.
GET /billings?situation=AWAITING_AGENCYdevolve exatamente asPENDINGvencidas de canalAGENCY_PIX, e o filtrostatusnão existe mais na lista agregada (RN24a). - [ ] CA45.
GET /billings?reference_month=sem-competenciae?reference_month=05/2026respondem 422INVALID_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_paidecharge_paidrefletem o corpo enviado, epaid_atguarda 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_idpassa a apontar para o novo (RN20b). - [x] CA49. Numa competência com uma cobrança cancelada, o
previstodo 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/summarysemreference_monthresponde 422REFERENCE_MONTH_REQUIRED(RN27). - [x] CA51.
AGENCY_USERrecebe 403 emGET .../billings/{bid}/chargeda própria garantia, por não terbillings:update— e não por sigilo: ela vê o encargo projetado na lista (RN13) e a carência emGET /settings(RN10a). O endpoint é do ato de dar baixa (RN14, seção 10.1). - [ ] CA52.
AGENCY_USERenviandocollects_monthlyemPATCH /partner-agencies/{a própria}recebe 403 —PartnerAgencyService.updatejá exige serADMIN_GLOBALouADMIN_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 = 1000distribuído emguarantee_paid = 980echarge_paid = 20, gravadiscount_amount = 20; e a competência fechaprevisto = recebido + abatido + a_receber + a_repassar(RN19, RN27). - [ ] CA54. Uma cobrança avulsa de R$ 150 paga integralmente grava
misc_paid = 150, entra emrecebidoe aparece na linha Avulsas do resumo compago = 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,04eguarantee_paid = 0responde 422PAYMENT_ALLOCATION_MISMATCH, ainda que a soma feche; e valor negativo em qualquer balde responde o mesmo (RN16). - [ ] CA57.
AGENCY_USERlêcollection_method,interest_percent_monthelate_fee_grace_daysemGET /api/v1/settings— os três saem sem máscara, por decisão registrada (RN10a). - [x] CA58.
OPERATORrecebe 403 ao remover e ao editar o metadado do comprovante de uma baixa alheia, e oreceipt_document_idda 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: enumCollectionMethode os três campos — model, schema (Create / Update / Response +json_schema_extra), migrationmeio_de_cobranca_e_encargo(RN8, RN10) - [x]
PartnerAgency:collects_monthly— model, schema, migrationimobiliaria_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 nocreatenem noupdate(CA42)
Fase 2 — apps/api, a classificação
- [x]
Billing: os dois enums e as seis colunas de classificação;reference_monthviraNOT NULL— model + migrationclassificacao_de_cobranca, com o passo de dados da seção 11 (RN1–RN5) - [x]
GuaranteeService._gerar_calendario_de_cobrancapassa 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
downgradede 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_projectedna leitura (RN13) e o endpointcharge(RN14) - [x]
BillingPayRequeste opay(RN15–RN19, RN22) - [x] Colunas de baixa em
Billinge migrationbaixa_detalhada(RN15) - [x]
Document.billing_id: model,_VALID_ENTITY_TYPES(que deixa de derivar de_ENTITY_REPOS), os doiscol_map,_owned_by_agency, router, migrationcomprovante_na_cobranca(RN20, RN21) - [x]
BillingRepository.get_in_scope(billing_id, current_user), juntandoGuaranteee aplicando tenant e imobiliária no mesmowhere; e o ramo explícito debillingem_validate_entity, antes do caminho genérico.billingnão entra em_ENTITY_REPOS(RN20) - [x] As duas guardas de escrita de comprovante, e a exigência de
billings:updatenos 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
cronno worker, às 7h, comtimeout=600erun_at_startup=False(RN23);situationderivada (RN24) - [x] Filtro
situationsubstituindostatus(RN24a) e validação dereference_month(RN5) - [x]
overdue_only(RN25),summary(RN26, RN27),exportcomopenpyxl(RN29), e a guarda de tenant dos dois agregados (RN26a) - [x]
billings:exportnorbac_seed.py, com a subtração doADMIN_GLOBALcomentada - [x]
_monthly_financialtroca o recorte (RN28) e odescriptiondo endpoint é atualizado no mesmo diff - [x] Os três
action_urlda 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) eTAGS_METADATAda tag Cobranças
Fase 5 — apps/web
- [x]
PageTabscompartilhado, com o teste unitário dele - [x] Tela de Cobranças: abas, indicadores, resumo, filtros, as nove colunas, a fila
- [x]
BillingPayDialognos quatro blocos, eBillingListdo detalhe em oito colunas - [x] Parâmetros e Imobiliárias
- [x]
nav-badgespassando a ser alimentado pela contagem, e atualizado após cada ação - [x]
data-testidem 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 comcollects_monthlyligado — em vez de trocar ocollection_methoddo tenant, que é compartilhado com a suíte inteira. O plano dizia "meio de cobrança ecollects_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-filterviroubillings-situacao-filter, o sentinelasem-competenciasaiu, o link da Central passou a ser?aba=atrasadose 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) emake lintsem apontamento novo nos arquivos tocados — pytest 1224 verdes, web 438 em 54 arquivos,tsclimpo; noruff, os arquivos tocados têm exatamente os mesmos apontamentos que têm namain(E501eI001de um repositório que nunca passou porruff format, mais osF821de forward-ref do SQLAlchemy) - [x]
/devremote-rebuilddeapi,webeworker— 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 == 0em 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.mdda 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
Criticalnovo — 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
paycresceu 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 empurraramnotesde 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 opreflightdepois de escrever o código, os dois se confundem.A tabela escreve "linha N" em vez de
arquivo:linhade 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
preflightconfere 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 primeiropreflightdepois 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 pedemfireEvent.change.- O
MemoryRouterdos testes não escreve emwindow.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:
- 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 oADMIN_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:apiReqtrata 403 antes de quem chamou, comwindow.location.href, e nenhum.catch()impede — o resumo semtolerate403expulsaria 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". - Uma regressão que a fase criou. O campo
collects_monthlyviajava 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. - Os
data-testidda 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-*emIndicadoresDaCompetencia.tsx, novepay-*emBillingPayDialog.tsx), e é assim mesmo: a seção 11 diz o que o e2e vai usar, não proíbe outros, e oCLAUDE.mdexigedata-testidem 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 emslotProps.htmlInputde umTextField select, onde o MUI não o repassa — o identificador era declarado e nunca chegava ao DOM, que é exatamente a armadilha queagencies/form.tsxjá 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 sobbasename(/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
catchsem 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. VirouformatarDataISOemdateUtils, ao lado dehojeLocalISO— 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:
acceptpróprio e nenhuma guarda de tamanho, comACCEPT_DE_DOCUMENTOSeMAX_BYTES_DE_DOCUMENTOjá publicados emcomponents/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=Truenoupdatedescarta onullexplí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 usaexclude_unsetpara 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
ifdo 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
messagenovo nosdetailsnã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
descriptiondoPATCH /settingsensinava o contrário do código. Ele ainda dizia "PATCHnão os devolve a nulo — quem quer zerar envia0", que era a decisão da RN10 antes de a Fase 5 descobrir que0enullnã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/webrodava 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. FixarTZ=America/Sao_Paulonovite.config.tspõ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
nullexplí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.00comohelperText, encostado noR$ 1.022,00que oapps/webformata — duas grafias do mesmo número no mesmo formulário. O_brldeservices/credit.pysubiu paraapp/domain/moeda.pyem vez de virar a segunda cópia. - Fecharam junto os dois
detailssem teste (abono com encargo, piso negativo), o ramo de desmarcar o abono e um ternário morto emagencies/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 deobjeto.idestoura comMissingGreenlet: ler um atributo expirado dispara carregamento preguiçoso fora do greenlet do SQLAlchemy async. Os ids saem antes doexpire_all().- A forma da resposta da Central é
current_month+history, não uma listamonthly. 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_paidediscount_amountcrus; 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 exporprincipal_e_encargo_recebidoseabatido_deno 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_urlda 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
PAIDouCANCELLEDprojeta 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 NULLcom 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 comcreateoudelete, 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
BYTEAda via anterior, e não só marca a remoção lógica que a RN20b pede. É o mesmo desfecho dodelete()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: odelete()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 exigiabillings:updatesem nomear a recusa, eForbiddenErrorcru sai comoFORBIDDEN— 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 fariainstallment_totalvaler 13 num plano de 12 e deslocaria as posições, divergindo domeses_de_vigenciaque 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 oCritical. - O Swagger do
POST .../billingspassou a dizer que a avulsa nasceMISC, 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 NULLque virou código morto. - Três testes fracos foram reforçados: o do canal-padrão (que passaria com o
channelremovido da geração, por ser oserver_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.updatealteradue_datesem tocarreference_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 responde422. É 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 —
- 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.
billings-status-filterviroubillings-situacao-filter(RN24a), e Cancelado virou Cancelada.- O sentinela
sem-competenciasaiu (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. - 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. - 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:
- "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.
- A fila é por vencimento, não por status. Eu carimbava
OVERDUEnuma 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. FilterSelecteFilterPillrendem papéis diferentes —optionno desktop,menuitemem 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.- A aba do comprovante é opaca ao Playwright. Ela é aberta com
noopener, e o que se lê da URL dela não é o blob nemabout:blank— voltou":". A asserção mudou de alvo: o que importa, e o que quebraria se alguém trocasse o blob por umhrefdireto (que é o erro natural aqui, e não funciona porque a API exige o token), é a chamada ao/downloadcom 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=SETUPsomasse 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 semtenant_idnowhere, e a revisão pediu que entrasse. Pôr umtenant_idúnico ali quebraria o caso que existe: oADMIN_GLOBALlê 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=Falsenosa.Enumdopayment_channele na migration, explicando que era o que impedia o type already exists. O revisor mediu: o kwarg é dopostgresql.ENUM, e osa.Enumgenérico o descarta em silêncio (conferido em SQLAlchemy 2.0.49 — odialect_impldo PostgreSQL volta comcreate_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 é oadd_columndo Alembic não emitirCREATE 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 | |---|---|---| |
Billingnão tem campo de tipo nem de canal —billing.py:104-130| §1, bullet Canal | rodada 1 | |Billing.reference_monthestá embilling.py:121| §1, bullet Parcela | rodada 1 | |PartnerAgencynão tem nenhum campo financeiro —partner_agency.py:23-43| §1, bullet Canal | rodada 1 | |mark_overdueé a única escrita deOVERDUE, 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 porreference_month—apps/web/src/pages/billings/index.tsx:130| §1 | rodada 3 | | A Central recorta o mês por janela dedue_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 deoperations_center_service.py:569,583| §4, §5 | rodada 3 | | Não há campo de canal emtenant_settings.pynem empartner_agency.py| §1, bullet Canal | rodada 3 | | A RN2 da 0048 está emdocs/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 porstatus == 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 emBillingnem emGuarantee| §1, bullet Parcela | rodada 4 | |BillingOrigindistingueCALENDARdeMANUAL—billing.py:51,125| §1, nota sobre "canal" | rodada 4 | |emAtrasocalcula o atraso no front —billings/index.tsx:78-80| §1, §3 | rodada 1 | | As colunas da lista são as nove debillings/index.tsx:434-442| §1, §3 passo 2 | rodada 1 | |initial_feeefirst_month_upfrontsã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échannel_integration.py:9-12| §1, nota sobre "canal" | rodada 1 | |ADMIN_TENANTé o único papel de sistema combillings:update—rbac_seed.py:280-281, subtração em:228| §2 | rodada 1 | |ANALYST/OPERATOR/VIEWERtêmbillings:list_all—rbac_seed.py:329,359,379| §2 | rodada 1 | |AGENCY_USERnão recebebillings: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 usatotal_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_summaryexpõ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 consomestatusda 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 | |Guaranteenã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_GLOBALnão escreve cobrança (RN9) —docs/specifications/0048-gestao-de-cobrancas/specification.md:537| §2 | rodada 2 | | A RN19a está emdocs/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 | | OROADMAP.md:139-142diz queBillingnão é boleto emitido | §5 | rodada 1 | | O item E2 do roadmap está emROADMAP.md:237-242| §5 | rodada 1 |
Duas regras, e a segunda é a que faz a primeira valer:
- Só entra o que um revisor conferiu — não o que você conferiu ao escrever. O ponto é aproveitar trabalho já pago por uma rodada, não abrir atalho para afirmar sem prova.
- Mexeu na frase da spec, a linha sai da tabela. A afirmação volta a ser conferível. Sem isso a tabela vira o oposto do portão: uma afirmação carimbada como verdadeira porque um dia foi.
Feature sem afirmação sobre o repositório não precisa da seção — apague-a.