Skip to content

release(financial): NSA por convênio, Pix por chave e o ciclo de vida da conta-cedente - #1002

Merged
lekadecastro merged 22 commits into
mainfrom
dev
Sep 6, 2026
Merged

lekadecastro merged 22 commits into
mainfrom
dev

Conversation

@lekadecastro

Copy link
Copy Markdown
Contributor

Leva da dev para a main: 22 commits, 7 PRs, 63 arquivos (+12.567 / −1.047). Tudo em fin_* — nenhum outro módulo é tocado.

O que entra

PR Issue O que entrega
#981 #923, #945 O Pix por chave para de exigir conta, e o ISPB vira zeros
#982 #948 O pré-voo do Pix passa a checar as duas condições da chave
#994 #856 Os campos do cedente no header ganham fonte; o DV da agência sobrevive ao UPDATE
#997 #943 A sequência de NSA passa a ser do convênio (a conta irmã volta a gerar remessa), um deadlock na alocação é corrigido, e o backfill da 0057 vira idempotente
#998 #995 B1/B3/B8.1/B8.2 Reabrir e excluir conta-cedente; o convênio volta a ser editável na conta encerrada
#999 #995 O saldo de abertura passa a ser editável
#1001 #995 bloco A A chave natural da conta-cedente ganha forma canônica, mais quatro achados da revisão

O que importa para o deploy

Estado

Fora desta PR

A dependabot #973 (mysql2 3.22.3 → 3.23.1) aponta direto para a main em vez da dev. Decisão separada: redirecionar para a dev ou fechar.

https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg

lekadecastro and others added 22 commits September 5, 2026 11:52
…eros (#923, #945)

O Bradesco respondeu em 05/09/2026, por laudo técnico da equipe Multipag Pix/VAN, sobre
o arquivo de teste enviado em 02/09: *"O arquivo foi validado e não necessita de ajustes
em sua estrutura, estando apto para transmissão. Os questionamentos encaminhados abaixo
também estão corretos e em conformidade com o layout."*

Duas das seis perguntas estavam segurando código, cada uma numa issue, e as duas foram
respondidas com sim. É o que este commit implementa — nada aqui é decisão nossa.

ISPB = 00000000 (#923)

O emissor derivava o ISPB do código de compensação do favorecido, por uma tabela
embarcada do Bacen (#934), alinhado ao golden. O golden preenche o campo porque PODE:
o manual v08 exige o P015 só para "TED para instituição financeira que não possui código
COMPE", que não é esta rota. Com o laudo, os dois lugares que pedem o ISPB — o P015 do
Segmento B (233-240) e o complemento do G031 no Segmento A (192-199) — leem uma constante
só, `PIX_ISPB_ZEROS`. Um literal, porque é um fato só: dois valores separados divergiriam,
e o arquivo afirmaria duas instituições para o mesmo pagamento.

Some com a tabela a recusa `payee-ispb-unknown`, e ela era a mais cara das três da #948:
vinha DEPOIS do `allocateNsa`, então cada favorecido de banco fora da tabela queimava um
número da série antes de o operador descobrir que não daria. Sem tabela não há banco a
desconhecer, e um erro que não pode mais ocorrer é um caminho que os chamadores
continuariam tratando à toa — por isso sai da união inteira, até o use case.

⚠️ Se o campo voltar a ser exigido, a origem é o DICT, nunca uma tabela local. A tabela
responde "qual o ISPB do banco X?"; o arquivo pergunta "qual o ISPB da instituição que
detém ESTA chave?". Coincidem enquanto a chave não sofrer portabilidade, e quando deixam
de coincidir nada sinaliza.

Bloco bancário do favorecido zerado (#945)

A sexta pergunta era a possibilidade de enviar banco, agência e conta do favorecido
zerados no Pix iniciado por chave. Resposta: *"é possível enviar os campos referentes ao
banco, agência e conta do favorecido preenchidos com zeros."*

Isso derruba a premissa que a #838 introduziu. O argumento dela era que o golden traz o
bloco preenchido e o layout marca os campos com asterisco — mas o golden prova como
AQUELE arquivo foi montado, não o que o ERP deve coletar, e o asterisco, pela legenda do
próprio manual (p. 7), significa "merece atenção especial", não "obrigatório". O manual
chega a marcar `*G009` e escrever "(Campo Não Obrigatório)" na descrição.

A mudança que fecha a classe não é o valor, é o TIPO. `RemittancePixPayment.payee` passa
a ser `RemittancePayeeIdentity` — nome e inscrição — e o bloco bancário deixa de existir
nesta rota, do reader ao montador. Enquanto o campo estivesse lá, o dado disponível no
tipo seria o que o próximo emissor iria querer usar. Quem escreve as posições 021-042 é
`PIX_ZEROED_PAYEE_ACCOUNT`, no ramo `pix` do montador: a decisão é DA ROTA, não do
`segmentA`, que continua escrevendo a conta real na transferência.

⚠️ A coluna 043 (G012, DV agência/conta) continua em BRANCO. O laudo nomeia CINCO campos
e este não é um deles; zerar o bloco "inteiro" por simetria preencheria a posição que o
validador oficial recusa (#754). É a armadilha de uma leitura rápida da resposta, e tem
teste próprio.

Só com o emissor zerando é que o pré-voo pode relaxar, e a ordem importa: enquanto o
Segmento A lesse a conta do cadastro, um pré-voo permissivo aprovaria o que o montador
recusaria com `numeric-field-invalid`, depois do NSA alocado — a divergência que a #837
fechou. Feito nessa sequência, `checkRouteData` no `case 'pix'` volta a pedir só a chave.

Efeito colateral desejado, que aparece na tela: some o `check-digit-mismatch` no Pix.
Favorecido com DV divergente era bloqueado; passa a pagar, e está certo — o DV não vai
no arquivo. A régua de DV continua onde o dígito é escrito, que é a transferência.

Testes

Cinco testes falharam, e eram exatamente os que codificavam a premissa revertida. Foram
invertidos em vez de removidos, com a razão registrada: um teste que afirma o contrário
do anterior é o lugar certo para explicar por que a régua virou. Dois novos: a coluna 043
em branco, e o Pix que emite sem dado bancário algum.

Gate: typecheck + format:check + lint + 11667 testes, 0 falhas.

⚠️ NÃO fecha a #948 — sobram `pix-key-unrepresentable` e
`remittance-pix-key-type-unsupported`, que continuam sem contraparte no pré-voo. E não
toca a #980 (o Validador Universal não aplica o layout Pix), que é documentação e não
muda byte do arquivo.

Refs: #923, #945, #948, #708, #838, #837, #754
Assisted-by: Claude-Code:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKkFN7sQDoBS8AvteK1kZ
…chave (#948)

O emissor recusava duas coisas que o pré-voo não consultava — chave que não cabe nas 99
posições do `G101` e tipo de chave fora do domínio `G100` —, e as duas recusas vêm DEPOIS
do `allocateNsa`. Cada tentativa queimava um número da série, e a mensagem do 422 mandava
o operador conferir o pré-voo, que já o havia aprovado.

Nenhuma das duas é defesa contra dado malformado: as duas são alcançáveis por cadastro
perfeitamente legítimo. O cadastro aceita chave bem mais longa que 99 posições, e o
vocabulário de tipos de chave é de `partners`, que pode crescer sem que o `G100` cresça
junto.

FONTE ÚNICA, NO MOLDE DA #837

A #837 fechou esta classe de defeito uma vez, criando `domain/payout/van-routes.ts` como
fonte única de "esta rota tem emissor". Ela cobre a EXISTÊNCIA do emissor; o que faltava
eram as CONDIÇÕES dele. Entra `domain/payout/pix-key.ts`, com a mesma justificativa de
posição: quem precisa da régua é `checkPayoutReadiness`, que é domínio e é chamada como
função pura, sem deps — o que descarta a saída por port. Adapter alcança domínio; domínio
não alcança adapter. Então a fonte fica no domínio e o emissor desce até ela, como
`batch-profile.ts` já faz com `hasRemittanceIssuer`.

⚠️ Uma diferença registrada no arquivo: em `van-routes.ts` a lista é ESTADO DA
IMPLEMENTAÇÃO e caduca quando o emissor ganha rotas. Aqui as duas constantes são
PROPRIEDADE DO LAYOUT — as 99 posições e os cinco valores do `G100` só mudam se o banco
mudar o layout, e nesse dia mudam para os dois lados juntos. É o que torna uma fonte só o
desenho certo, e não uma conveniência.

O EMISSOR PASSOU A SER COBRADO PELO COMPILADOR

`pix-initiation.ts` montava um `Map` literal com os cinco tipos. Com a lista no domínio,
faltava o que impede os dois de divergirem: um tipo novo lá faria o pré-voo aprovar uma
chave que o emissor recusaria — exatamente a divergência que esta fatia fecha.

O mapa passa a ser construído a partir de um `Record<PayablePixKeyType, string>`, e
`Record` EXIGE a chave. Verificado: acrescentar um tipo à lista do domínio quebra o
typecheck apontando `pix-initiation.ts:70`, com o nome do tipo que ficou sem código.

O `Map` continua existindo, e não é redundância — as duas metades resolvem problemas
diferentes. O `Record` dá exaustividade; o `Map` dá segurança de protótipo, porque
`keyType` chega como string arbitrária e um `Record` consultado direto devolveria uma
FUNÇÃO para `'toString'`, que não é `undefined` e passaria pela guarda como se fosse
código `G100` válido. Há caso de teste fixando isso.

`PIX_KEY_WIDTH`, no montador, virou `PIX_KEY_MAX_WIDTH` do domínio pela mesma razão: duas
constantes com o mesmo valor são duas réguas, e uma delas muda sozinha.

DOIS MOTIVOS DIFERENTES, E É DESVIO DELIBERADO DA LETRA DA CA1

A CA1 da #948 pedia `unmappable` para a chave longa demais. Uso `malformed`, e
`unmappable` só para o tipo não suportado. Os dois motivos existem para dizer coisas
diferentes ao operador (`types.ts`): `unmappable` é "ninguém sabe converter isto",
`malformed` é "está lá e precisa ser corrigido". A chave longa demais É conversível e É
uma chave — o que ela não é, é representável no campo. E usar o mesmo motivo nas duas
condições as tornaria indistinguíveis na tela, que é o que a lista de lacunas por CAMPO
existe para evitar.

Nenhum dos dois motivos é novo, então o front não precisa de mudança.

A CA8 SAI DE GRAÇA

`keyType` vazio cai na mesma régua de "não suportado". Hoje o reader o recusa derrubando
a geração INTEIRA — o contrato dele é tudo-ou-nada —, com o pré-voo tendo aprovado a
linha.

Sem chave, o pré-voo PARA em vez de acumular: listar "comprimento" e "tipo" de um campo
vazio mandaria o operador corrigir o que não existe. Com chave, as duas condições
acumulam, pela mesma disciplina do boleto.

TESTES

Um teste foi INVERTIDO: "não julga o tipo da chave, apenas a existência dela". Ele se
apoiava em duas premissas — o vocabulário é de `partners`, e duplicá-lo criaria segunda
fonte da verdade. A primeira continua verdadeira; a segunda deixou de valer, porque a
fonte não está duplicada. E o que derrubou a separação "cadastro completo?" × "sei emitir
isto?" foi o custo: a recusa vem depois do NSA.

`pix-key-single-source.test.ts` é a CA3 desta fatia, e mede OS DOIS LADOS DE FATO em vez
de inspecionar tipos. Metade das garantias já é do compilador, mas compilador só cobra o
que está ligado: se alguém reescrever o mapa com chaves soltas ou voltar a escrever `99`
no montador, a compilação segue verde. Cada caso executa as duas pontas e compara
desfechos — incluindo o trim, porque medir a chave crua de um lado e aparada do outro é
uma divergência de UMA posição, invisível em revisão.

Gate: typecheck + format:check + lint + 11678 testes, 0 falhas.

⚠️ Empilhado sobre o PR #981 (#923/#945) — mexe no mesmo `case 'pix'`. Mesclar aquele antes.
⚠️ NÃO fecha a #948: restam CA4, CA5, CA6, CA7, CA9 e CA11.

Refs: #948, #837, #838, #945
Assisted-by: Claude-Code:claude-opus-5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJKkFN7sQDoBS8AvteK1kZ
…-bancario-923-945

fix(financial): o Pix por chave para de exigir conta, e o ISPB vira zeros (#923, #945)
…ts-948

fix(financial): o pré-voo do Pix passa a checar as duas condições da chave (#948)
…cia para de ser corrompida (#856)

Os três campos que o header CNAB escrevia por literal passam a sair do cadastro ou de uma
derivação — e o que continua em branco tem a razão escrita ao lado.

- 018 (G005) era `'2'` fixo: todo cedente saía declarado pessoa jurídica. Agora deriva da
  inscrição, pela MESMA função que o reader usa para o favorecido. As duas cópias privadas do
  reader subiram para `domain/payout/inscription.ts`.
- 058 (G009) era `''`: o DV que o operador digita na tela desde 25/08 não tinha onde ser gravado.
  Ganhou coluna, contrato (opcional no create/edit, presente na leitura) e migration aditiva.
- 072 (G012) FICA em branco, e agora justificado: o campo é a 2ª posição do DV para bancos de DV
  com duas posições (layout v08, p. 96); o do Bradesco tem uma só. Confirmado pela inquiry-0033,
  que mediu o campo vazio em três arquivos aceitos pelo Validador Universal.

A correção maior é uma que a issue não pedia e o código pedia. O ETL gravava a agência legada
INTEIRA, com o DV embutido (`1234-5`), e `digits(agency, 5)` remove o separador antes do pad: as
posições 053-057 saíam `12345` onde o banco espera `01234`. Cinco dígitos, cabe no campo, o
inspetor aprova — ele valida forma, e a forma fica perfeita — e o arquivo ia ao banco apontando
outra agência em toda remessa daquela conta. O ETL passa a decompor pela gramática do domínio, e
`checkCedenteAgency` recusa o que sobrar, antes do NSA.

Para a conta JÁ cadastrada, preencher o DV não conta como alteração de dado bancário — é a mesma
porta que a #722 abriu para o convênio. Sem isso a trava FR-008 deixaria toda conta em uso no beco
medido em produção (#942/#943): campo vermelho permanente e Salvar desabilitado para todos os
outros campos.

CA3 (CNPJ alfanumérico do cedente, ADR-0044) entra como recusa de pré-voo com slug próprio, sem
tocar no emissor — o lado do favorecido e o `inscription()` posicional são da #863, em andamento.

`checkCedenteRemittanceReadiness` foi partida em três réguas. A edição de conta pergunta só pelo
convênio: com a agência dentro da régua única, uma conta de agência malformada responderia
"convênio não serve" e destravaria a troca de um convênio perfeito.

Gate: typecheck + format:check + lint verdes; 11729 testes, 0 falhas (baseline 11678).

Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…uncado (#856)

Achados da revisão deste próprio PR. O pior deles tornava a issue meio-entregue em silêncio.

O UPDATE DESCARTAVA A COLUNA

`cedente-account-store.drizzle.ts` faz `onDuplicateKeyUpdate({ set: … })` com a lista de colunas
escrita à mão. O `values(row)` do INSERT vem de `toRow` e ganhou `agencyDigit` de graça; o `set`
não. Efeito: a conta NOVA saía certa e a EDIÇÃO não — o use case devolvia 200 com o dígito ecoado
do agregado em memória enquanto `agency_digit` ficava NULL, e a 058 continuava em branco.

O compilador não cobra (`$inferInsert` torna coluna nullable OPCIONAL no tipo) e o fake não cobra
(o in-memory troca o objeto inteiro por spread). Quem devia cobrar é o contrato compartilhado, e
ele asseria `status` e `nickname` — por acaso, duas colunas que ESTAVAM na lista. Agora ele compara
o snapshot inteiro com `deepEqual`, então toda coluna futura entra sozinha.

TRIM NA RE-HIDRATAÇÃO — DOIS DEFEITOS NUMA LINHA

O mapper testava `.trim() !== ''` e gravava o valor BRUTO. Um `' 5'` na coluna produzia `text(' 5', 1)`
→ `' '`, isto é, 058 em branco com o dígito presente no banco; e fazia `wantsAgencyDigitSwap`
comparar contra o `'5'` que o construtor e a edição gravam, lendo reenvio idêntico como TROCA e
disparando a trava FR-008. As três cópias da regra agora aparam igual.

ACEITAR-E-TRUNCAR NO CAMPO QUE A ISSUE CRIOU

A borda aceitava `max(2)` num campo de UMA posição. `text('12', 1)` descarta o segundo caractere e
`text('-2', 1)` grava um `-` literal na 058 — em silêncio, num campo de identificação bancária.
Borda passa a `max(1)`; o domínio ganha a régua do alfabeto do manual (dígito, `X` para resto 10,
`P` no Bradesco quando o resto é 1 — 4008-523-0096 v16, p. 30), para quem não passa pela borda.

O NÚMERO DE AGÊNCIA REAL QUE EU ESPALHEI

O commit anterior tirou um número de agência de uma fixture declarando-o "plausivelmente copiado do
cadastro real" e escreveu o MESMO número em nove lugares novos, seis deles em `src/`. E
`tests/etl/fixtures/legacy-mini.sql` — a fixture, alvo literal da regra — seguia intocada. O
CLAUDE.md diz que explicar a correção citando o dado a repete, e foi exatamente o que aconteceu.
Zero ocorrências agora; a FORMA (`NNNN-D`) é o que os comentários precisavam, e ela não depende do
número.

Fora de escopo por decisão da P.O.: os achados sobre conta migrada pelo ETL (agência com separador
sem via de correção, e o documento `PENDENTE-D6`). O cliente está recadastrando as contas com saldo
atualizado, então a linha migrada não é o caminho em uso.

Gate: typecheck + format:check + lint verdes; 11737 testes, 0 falhas (baseline 11678).

Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
fix(financial): os campos do cedente no header CNAB ganham fonte de dado (#856)
… irmã volta a gerar remessa (#943)

O mesmo contrato multipag vale para várias contas de pagamento — confirmado com o gerente do
Bradesco em 02/09. O contador vivia em `fin_cedente_accounts.next_nsa`, uma linha por conta, e toda
conta nova nascia em 1: duas contas sob o mesmo convênio emitiam o mesmo número sob o mesmo
contrato.

O DANO NÃO ESPERAVA O BANCO

A issue descrevia o efeito como "o Bradesco lê retransmissão". Ele é pior e mais imediato:
`fin_remittance_payables.your_number` é `<convênio><NSA><sequência>` — três componentes, NENHUM
deles tempo — com UNIQUE global. Duas contas do mesmo convênio, ambas em `000001`, produziam a
MESMA referência para o primeiro título, e o segundo INSERT era recusado pelo índice. O operador
recebia `An internal error occurred` e a conta simplesmente não gerava remessa. É o bloqueio medido
em produção na #942, cujo log (`errorCode: remittance-persist-failed`, e o `saveAll failed` no
INSERT de `fin_remittance_payables`) fecha o diagnóstico.

O nome do arquivo escapava da colisão porque carrega o timestamp até o segundo. A referência não
carrega nada disso — por isso o sintoma apareceu na tabela de vínculo, e não na de remessas.

O DESENHO

Tabela `fin_convenio_nsa` (convênio como PK), `domain/cedente/nsa-sequence.ts`, e o
`SELECT … FOR UPDATE` trocando de alvo: locka a linha do CONVÊNIO, que é onde as contas irmãs
precisam se serializar. Port e use case ficam intactos — `allocateNsa(cedenteAccountId)` continua
valendo, porque a conta ainda diz QUAL convênio.

A ordem dentro da transação é regra, não arrumação: confere a conta ANTES de tocar a sequência.
Invertida, uma conta encerrada queimaria um número do convênio inteiro — com o contador na conta o
dano ficava contido nela.

O BACKFILL É A PARTE PERIGOSA, E A REGRA É `MAX`

`next_nsa` é o PRÓXIMO a alocar, então o máximo do grupo é ≥ qualquer número já emitido por
qualquer conta daquele convênio. Começar abaixo reemite — e as referências `your_number` antigas
continuam gravadas, então um contador que retrocede bate no mesmo UNIQUE contra linhas históricas.
Contas `Closed` entram no GROUP BY: os números que gastaram existem.

TESTES QUE ESTAVAM VERDES DESCREVENDO O DEFEITO

O contrato compartilhado usava um convênio fixo para todas as contas e media o contador NA CONTA —
com isso, duas contas irmãs alocando `1` cada uma passavam. Agora cada conta nasce com convênio
próprio (isolamento) e há caso explícito para o compartilhamento, que é o CA1: falha contra o
modelo antigo.

⚠️ Três asserções ficaram VAZIAS com a mudança e foram refeitas, não removidas: elas conferiam
`next_nsa` da conta depois de uma recusa, e essa coluna não se move mais nem no sucesso — passariam
com o número queimado ou não. A prova honesta é a alocação seguinte, e é o que `assertNoNsaBurned`
faz. Duas delas eram minhas, do #856.

DECISÃO EM ABERTO (do plano da issue): `fin_cedente_accounts.next_nsa` fica como coluna vestigial,
congelada. Não a dropei nesta release — rollback seguro vale mais que limpeza, e o contador antigo é
recuperável de `fin_remittances`, que guarda o NSA emitido e a conta de origem.

CA3 (concorrência no MySQL real) NÃO está coberto: exige teste de integração no molde de
`nsa-allocation.drizzle-mysql.test.ts`, e o lock mudou de linha. Fica declarado, não escondido.

Gate: typecheck + format:check + lint verdes; 11743 testes, 0 falhas (baseline 11737).

Refs: #943, #942
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…e é consertado (#943)

O CA3 pedia concorrência entre contas IRMÃS contra MySQL real. Escrito, ele reprovou o commit
anterior — e o defeito era meu, não do teste.

O DEADLOCK

Quando a linha do convênio ainda NÃO existe, o `SELECT … FOR UPDATE` não trava uma linha: trava o
GAP, porque em REPEATABLE READ (o default) é isso que o InnoDB faz com um predicado que não casa
nada. N transações do mesmo convênio novo pegam o MESMO gap, todas seguem, e todas tentam inserir a
mesma PK — `ER_LOCK_DEADLOCK`, que o `catch` traduzia em `cedente-account-store-unavailable` (503).

É a mesma armadilha que `.claude/rules/adapters.md` já registra para o claim do outbox, e ela
mordia justamente o cenário de produção: conta nova, convênio novo.

O conserto é materializar a linha ANTES de travá-la — `INSERT … ON DUPLICATE KEY UPDATE` com `set`
no-op. Aí o lock passa a ser de LINHA, na PK: a segunda transação espera a primeira e enxerga o
valor dela. Quem move o contador é o UPDATE do passo seguinte, sob o lock já adquirido; escrever no
`ON DUPLICATE` sobrescreveria o trabalho de quem chegou antes.

Medido: 6 alocações concorrentes de 6 contas do mesmo convênio devolvem 1..6, sem repetir.

DOIS PROBLEMAS DE ISOLAMENTO QUE A MUDANÇA CRIOU, E QUE O CI PEGARIA

1. `fin_convenio_nsa` não tem FK para a conta, então apagar `fin_cedente_accounts` deixava a linha
   da sequência viva com o contador do run anterior. O convênio virou espaço de chave tanto quanto
   a agência: `CONTRACT_CONVENIO_PREFIX` no contrato, prefixo próprio no arquivo de integração, e
   limpeza na ENTRADA nos dois.

2. O caso da chave natural usava agência `4322` e convênio `9999999` — os dois FORA dos recortes
   que o `beforeEach` limpa. Resíduo pré-existente, trazido para dentro do espaço limpo.

ASSERÇÕES QUE FICARAM VAZIAS

Duas conferiam `fin_cedente_accounts.next_nsa` depois de alocar. Essa coluna não se move mais nem no
sucesso — passariam com o contador certo ou errado. Refeitas para conferir pela ALOCAÇÃO seguinte,
que é onde a garantia é observável.

⚠️ O QUE NÃO FOI MEDIDO, e não vou dizer que foi: o runner de integração faz `down -v` ao final, então
cada execução parte de volume novo. As duas execuções verdes NÃO exercitam resíduo — a limpeza do
item 1 é defensiva e segue a intenção da regra, mas a propriedade "passar duas vezes sem recriar o
banco" (`.claude/rules/testing.md`) continua por medir aqui.

Gate: typecheck + format:check + lint verdes; 11743 testes, 0 falhas.
Integração financial contra MySQL 8.4 real: 205/205, duas execuções.

Refs: #943, #942
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
… e vira idempotente (#943 CA4)

O CA4 dizia que a sequência de cada convênio nasce em `MAX(next_nsa)`. Ele não estava coberto, e a
razão de não estar é a que torna a lacuna perigosa: **as migrations rodam contra banco VAZIO** no
`before` de cada arquivo de integração. Um `INSERT … SELECT` sobre tabela vazia insere zero linhas e
passa. A migration mais arriscada do módulo era a única que o CI exercitava sem dados — e em produção
ela roda sobre contas reais, no job de migration, ANTES de o app subir.

O arquivo novo executa o statement da migration sobre dados semeados e cobra as quatro regras que
não podem se perder: `MAX` (nunca começar abaixo de número já emitido), conta `Closed` entrando no
agrupamento (os números que ela gastou existem), convênio vazio ficando de fora, e o `TRIM` dos dois
lados — sem ele, ` 930005` e `930005` viram dois grupos que colidem na mesma PK depois do `TRIM` do
`SELECT`, e a migration ABORTA.

⚠️ O ARQUIVO PRECISOU SER REGISTRADO EM `scripts/ci/test-integration.ts`. A lista de paths é
explícita: teste de integração fora dela nunca roda, em silêncio — a mesma classe de defeito que a
`rules/testing.md` registra para o sufixo `.e2e.ts`.

A MIGRATION FICOU IDEMPOTENTE, E ISSO DEIXOU DE SER LUXO

`ON DUPLICATE KEY UPDATE next_nsa = GREATEST(next_nsa, VALUES(next_nsa))`.

Ela roda uma vez pelo journal, mas o job que a executa vive no pipeline de deploy que está falhando
(#996) e pode ser retentado. Um `INSERT` cru abortaria com `ER_DUP_ENTRY` na segunda passada e
derrubaria o deploy com o banco já alterado — trocaria um deploy que falha por um que falha pior.

O `GREATEST` resolve os dois lados, e o segundo é o que importa: entre a primeira execução e a
retentativa, contas podem ter emitido. Um backfill que sobrescrevesse com o `MAX` antigo reabriria
faixa já usada — exatamente o dano que a #943 existe para impedir. Há caso fixando isso.

O teste recorta por prefixo de convênio, e a diferença está declarada no código: a migration varre a
tabela inteira uma vez, contra `fin_convenio_nsa` vazia; aqui convivem as contas dos arquivos irmãos,
cujos convênios já ganharam linha pelas alocações deles.

Gate: typecheck + format:check + lint verdes; 11744 testes, 0 falhas.
Integração financial contra MySQL 8.4 real: 211/211.

Refs: #943, #996
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
fix(financial): a sequência de NSA passa a ser do convênio — destrava a conta irmã em produção (#943)
 B8.1)

A trava do #722 recusava trocar um convênio já preenchido em QUALQUER conta, sem olhar o status. Em
06/09 isso empurrou a operação para um `UPDATE` direto no banco de produção — a mesma classe de
intervenção que a #879 mostrou custar caro.

POR QUE A TRAVA NÃO ALCANÇA A CONTA ENCERRADA

O #722 a criou por uma razão específica: o convênio viaja no NOME de toda remessa transmitida
(`PAG_<convênio>.<timestamp>_<NSA>.REM`), e reescrevê-lo faria as remessas antigas apontarem para um
contrato que a conta não declara mais.

Conta encerrada não gera remessa nova — não há nome a produzir. E o histórico não corre risco: as
remessas antigas guardam o PRÓPRIO nome de arquivo em `fin_remittances`, não uma referência viva ao
cadastro. Travar o campo ali não protegia nada; só fechava a via da tela.

O invariante do #722 não afrouxa onde ele importa: conta ATIVA segue recusando a troca, e há caso
fixando os dois lados.

⚠️ E A TRAVA FR-008 CONTINUA VALENDO. Encerrada não vira passe livre: agência, conta e dígito seguem
barrados por histórico. As duas travas olham coisas diferentes — uma o convênio, outra o dado
bancário —, e só a primeira muda aqui. Há caso fixando isso, porque é o modo de falha óbvio de quem
lesse o diff rápido demais.

O QUE ESTE COMMIT NÃO FAZ, E POR QUÊ

O B8 da issue tem duas metades. Esta é a primeira. A segunda — `000000` virar convênio
RECONHECIDAMENTE inativo — NÃO entra, e a razão é medida, não preguiça:

  · `000000` é o valor RESERVADO de mascaramento de fixture deste repositório, cobrado por
    `tests/cleanup/bank-fixture-masking.test.ts` e alinhado com o `van-agent` do outro lado do
    contrato. DEZ arquivos de teste assertam `PAG_000000` — retorno da VAN, quarentena, outbox,
    envelope de status. Torná-lo inválido obrigaria essas gerações a migrar para `999999`, que tem
    significado documentado DIFERENTE ("arquivo de outro convênio").

  · A própria issue oferece a alternativa: *"ou a desativação usa convênio vazio, que já significa
    exatamente isso em todo o caminho"*. Vazio custa zero churn e já é recusado com
    `cedente-convenio-missing`, antes do NSA.

A escolha do sentinela é da P.O., e está registrada na issue com a medição.

⚠️ E A URGÊNCIA DO B8 MUDOU: o B8 existia para impedir a colisão de NSA entre duas linhas do mesmo
convênio. Com a #943 mergeada, o contador é do CONVÊNIO — as duas linhas passam a compartilhar uma
série (1, depois 2), e os `your_number` saem distintos. A colisão que zerar o convênio contornaria
não existe mais. O B8 continua valendo como higiene e como via de correção pela tela, não como
destravamento de produção.

Gate: typecheck + format:check + lint verdes; 11747 testes, 0 falhas (baseline 11744).

Refs: #995, #722, #879, #943
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…ada (#995 B8.2)

Decisão da P.O. (06/09): o sentinela de "numeração desativada" é a string VAZIA, não `000000`.

POR QUE VAZIO, E NÃO `000000`

Vazio já significa exatamente isso em todo o caminho, e a propriedade que importa é a ORDEM:
`checkCedenteConvenio` o recusa com `cedente-convenio-missing` ANTES do `allocateNsa` — e o número
não volta. `000000` seria ACEITO pela régua (não é vazio, é numérico, cabe em 6), então a linha
zerada continuaria contando como apta a pagar; a recusa viria do banco, depois do NSA queimado.
É o mesmo padrão da #942.

E `000000` já tem dono neste repositório: é o valor RESERVADO de mascaramento de fixture, cobrado
por `tests/cleanup/bank-fixture-masking.test.ts` e alinhado com o `van-agent` do outro lado do
contrato. Dez arquivos assertam `PAG_000000`. Dar-lhe um segundo sentido colidiria com todos eles e
empurraria as gerações para `999999`, que documenta significado diferente ("arquivo de outro
convênio", ADR-0061).

O QUE MUDOU: UMA LINHA NA BORDA

`editCedenteAccountBodySchema.convenio` perde o `min(1)`. Era ele o bloqueio real — o use case
sempre soube lidar com vazio, mas o pedido morria em 400 antes de chegar lá. Foi isso que empurrou a
correção de 06/09 para `UPDATE` direto no banco de produção.

⚠️ Quem PODE limpar continua sendo decidido no use case, não no schema: conta ATIVA com convênio
válido segue recusando qualquer troca (#722), inclusive para vazio. Sem essa metade, o B8.2 viraria
um jeito de desativar a numeração da conta que está pagando — há caso fixando os dois lados.

O CASO DE BORDA É O QUE VALE

O teste do use case ficaria verde com a operação ainda impossível pela tela, porque o defeito não
estava nele. O caso novo em `financial-cedente.http.test.ts` assere `!== 400` e diz na mensagem o que
um 400 ali significaria: o `min(1)` de volta.

Gate: typecheck + format:check + lint verdes; 11750 testes, 0 falhas (baseline 11747).

Refs: #995, #722, #942, #943
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…xa de ser beco (#995 B1/B3)

Em 06/09 um encerramento por engano em produção deixou a conta inacessível pelos DOIS caminhos: não
havia rota para reabrir, e o recadastro batia em `cedente-account-duplicate` porque a linha encerrada
continua ocupando a chave natural. A saída foi `UPDATE` direto no banco — a intervenção que a #879 já
mostrou custar caro.

DUAS AÇÕES, SEMÂNTICAS OPOSTAS (desenho da P.O., 06/09)

  Encerrar  Active → Closed    fica no grid   chave OCUPADA    reversível
  Excluir   Closed → Deleted   sai do grid    chave LIBERADA   irreversível

A assimetria é o desenho: encerrar é reversível porque o operador erra; excluir não é, e por isso
exige o passo deliberado de encerrar antes.

SOFT DELETE, OBRIGATORIAMENTE

"Mantém o histórico" não é preferência, é estrutura: remessa, conciliação e extrato apontam para a
conta, e as FKs do módulo são `RESTRICT`. Um `DELETE` físico nem passaria — estouraria
`ER_ROW_IS_REFERENCED_2` na primeira conta que já pagou algo, e destruiria o rastro do que foi
enviado ao banco, que é o que essas tabelas existem para guardar.

⚠️ O DISCRIMINADOR DA CHAVE NATURAL, QUE É A PARTE NÃO-ÓBVIA

O B4 exige que a excluída LIBERE a chave e a encerrada continue ocupando-a. A UNIQUE cobria as
quatro colunas independente do status, então a linha soft-deleted seguiria bloqueando.

Coluna `natural_key_slot` entra na UNIQUE como quinta parte: `'LIVE'` na linha viva (constante ⇒ duas
contas com a mesma chave colidem, que é o FR-016), o próprio `id` na excluída (único ⇒ nunca colide).

NÃO usar NULL no lugar de `'LIVE'`: em MySQL, linha com NULL numa coluna do índice único não conta
como duplicata — o efeito seria o INVERSO, com a unicidade sumindo em silêncio.

A derivação vive num lugar só (o mapper), e o CHECK `status <> 'Deleted' OR slot = id` é a rede para
quem escrever por outro caminho. A migration não precisa de backfill: o `DEFAULT 'LIVE'` já é o valor
correto para toda linha existente.

⚠️ `uuidKey` e não `varchar(36)` cru — a coluna guarda um identificador, e comparação de UUID é byte
a byte. `utf8mb4_unicode_ci` acharia `A` igual a `a`, e dois ids distintos poderiam colidir na UNIQUE,
prendendo a chave de novo. O gate `identifier-collation-from-type` pegou isto, e estava certo.

B7 — A MENSAGEM DO DUPLICADO PASSA A DIZER O QUE FAZER

Antes: "já existe uma conta com esta chave". Quem lia não tinha como saber que a conta existente
estava ENCERRADA, invisível no grid ativo, nem que havia caminho. Agora a frase nomeia as duas saídas.

O CASO DE TESTE CENTRAL MEDE AS DUAS METADES JUNTAS

Excluir tem de tirar do grid E liberar a chave. Um teste só da listagem passaria com a chave ainda
presa — o operador continuaria no beco, que é o estado que motivou a issue. O caso confere os dois,
mais o B5 (a conta segue legível por id), num fluxo só.

Gate: typecheck + format:check + lint verdes; 11765 testes, 0 falhas (baseline 11750).
Integração financial contra MySQL 8.4 real: 211/211 — a migration aplica e a UNIQUE de 5 colunas vale.

⚠️ NÃO fecha a #995: o bloco A (chave natural canônica, que é a CAUSA de a duplicata ter entrado) e
os CA8/CA9 (produção) seguem abertos. E o B8.3 é do web-app.

Refs: #995, #879, #943
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…a-encerrada-995

feat(financial): reabrir e excluir conta-cedente, e o convênio editável na encerrada (#995 B1/B3/B8)
… que evitava a duplicata (#995)

⚠️ ESTE COMMIT ATACA A ORIGEM OPERACIONAL DAS DUPLICATAS, e ela não estava descrita na issue.

A conta migrada do legado veio com o saldo congelado do começo do ano, e `openingBalanceCents` /
`openingBalanceDate` só existiam na CRIAÇÃO. Sem poder corrigi-los, o operador criou contas NOVAS
para conseguir gerar remessa — e são essas as "duplicatas" que a #995 trata. Alinhar o saldo e
informar o multipag teria resolvido sem duplicar nada.

Isso reordena o bloco A da issue: a chave natural canônica, sozinha, teria BLOQUEADO o segundo
cadastro e deixado o operador sem saída nenhuma — não podia corrigir a primeira conta nem criar a
segunda. Trocaríamos "duplicata criada" por "operador travado". Tornar a linha do legado consertável
vem ANTES.

TRAVADO POR HISTÓRICO, JUNTO COM O DADO BANCÁRIO (FR-008)

O saldo de abertura é a BASE de todo saldo calculado. Mudá-lo numa conta que já importou extrato
reescreveria em silêncio o resultado de cada conciliação feita em cima dele, e nada apontaria a
causa. Por isso ele entra na mesma trava do dado bancário, e não numa régua nova.

A P.O. decidiu (06/09) manter a edição liberada nesta fase de testes e endurecer depois, quando
houver trilha de auditoria. O guard por histórico é o "depois" que já dá para ter agora: hoje
`hasActivity` é falso nessas contas, então ele não atrapalha o conserto — e passa a proteger sozinho
no dia em que a primeira conciliação existir.

FR-006 — O PAR É COESO, E A EDIÇÃO PODE QUEBRÁ-LO DE UM JEITO QUE A CRIAÇÃO NÃO PODE

O construtor recusa "um sem o outro" porque recebe os dois de uma vez. A edição recebe um PATCH:
mandar só os centavos numa conta sem saldo deixaria valor sem data — estado que o domínio nunca
produziria. A checagem é sobre o RESULTADO, não sobre o patch, e há caso fixando o outro lado
(numa conta que já tem o par, corrigir só um é legítimo) para que ninguém "conserte" a guarda
olhando o patch.

⚠️ O QUE ESTE COMMIT NÃO FAZ

Não há trilha de auditoria de conta-cedente — o que existe é um port `hasActivity`, booleano, usado
pela trava FR-008. O `fin_timeline_field_changes` é de DOCUMENTO, não de conta. A P.O. decidiu que a
auditoria vem depois; fica registrado que hoje uma edição de saldo não deixa rastro do valor
anterior.

Gate: typecheck + format:check + lint verdes; 11769 testes, 0 falhas (baseline 11765).

Refs: #995, #879
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
feat(financial): o saldo de abertura passa a ser editável — o caminho que evitava a duplicata (#995)
…#995, bloco A)

A busca de duplicata comparava as quatro colunas como STRING CRUA. A mesma conta bancária, escrita
de outro jeito, entrava de novo — e entrou, em produção, em 06/09:

  legado (ETL)              digitado na tela        o sistema via
  bankCode      '7'         bankCode      '007'     diferentes
  agency        '1234-1'    agency        '1234'    diferentes
  accountNumber '0012345'   accountNumber '12345'   diferentes

Para o banco é UMA conta. O segundo cadastro passou, e o teste de remessa não processou.

AS FORMAS, DEFINIDAS PELA P.O. (06/09)

  banco   — SEMPRE 3 dígitos com zeros à esquerda. Não é escolha nossa: é o que o CNAB grava
            (`num(bankCode, 3)`) e o que a tabela FEBRABAN do front já documenta como canônico.
  agência — o NÚMERO, sem o DV (coluna própria desde o #856) e sem zeros à esquerda.
  conta   — sem zeros à esquerda.
  dígito  — o caractere, em caixa alta (o `P` do Bradesco é DV legítimo).

⚠️ UM DEFEITO QUE OS PRÓPRIOS TESTES PEGARAM DURANTE A ESCRITA

A primeira versão usava `replace(/\D/g, '')` na agência. `digitsOnly('1234-1')` devolve `'12341'` —
CONCATENA a agência com o DV, que é exatamente a corrupção que o #856 corrigiu no emissor. Uma das
minhas expectativas de teste estava errada pelo mesmo motivo.

A gramática correta já existia e é `splitCheckDigit`, exportada no #856 e usada pelo ETL para
decompor a agência legada. Reescrevê-la aqui teria criado a segunda cópia que aquela issue eliminou.

A COMPARAÇÃO É EM TS, NÃO NO `where`

Escrever `LPAD`/`TRIM LEADING` no SQL resolveria a busca e criaria uma SEGUNDA definição da regra —
com o fake in-memory precisando de uma terceira. Duas réguas para o mesmo fato divergem na primeira
correção feita só numa delas; é a classe que a #863 e a #837 já documentaram neste módulo. A régua
vive no domínio e os dois adapters descem até ela.

O custo é varrer as linhas vivas em vez de usar índice. Aceitável por MEDIDA: esta tabela guarda as
contas bancárias DA ORGANIZAÇÃO — unidades, não milhares.

⚠️ O QUE ESTE COMMIT NÃO FAZ, E POR QUÊ

NÃO grava canonizado e NÃO cria UNIQUE canônica no banco.

  · Gravar reescreveria o cadastro do cliente: a agência `0288` viraria `288`, e a máscara `XXXX-DV`
    do front leria como incompleta. As colunas continuam com o que o operador digitou.
  · A UNIQUE canônica reprovaria a migration contra as duplicatas que JÁ existem — derrubando o
    deploy antes de o app subir. A P.O. decidiu (06/09) não limpar o cadastro por nós e deixar a
    funcionalidade de exclusão disponível para a operação usar conforme a necessidade dela.

Então: a régua bloqueia a duplicata NOVA agora; a garantia no banco entra quando o cadastro estiver
saneado. É o que a CA3 pede ao mandar REPORTAR colisões em vez de resolvê-las no código.

Fica declarado como pendência, não como esquecimento.

Gate: typecheck + format:check + lint verdes; 11776 testes, 0 falhas (baseline 11769).
Integração financial contra MySQL 8.4 real: 211/211.

Refs: #995, #856, #863, #837, #708
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…regressão minha (#995)

⚠️ ALTA — UMA LINHA RUIM DERRUBAVA TODO CADASTRO DE CONTA

Ao trocar o `where` em SQL por varredura com a régua canônica, passei a mapear TODAS as linhas vivas
e a abortar na primeira que `toDomain` recusa. Efeito: uma única linha legada com `type` fora do
union (ou status inválido, ou id não-UUID) fazia TODO `POST /cedente-accounts` responder 503 — a
checagem de duplicata morria antes de chegar na linha que procurava, e o operador não tinha como
saber por quê.

Antes, o filtro era SQL e só a linha candidata era mapeada: uma linha ruim em outro lugar da tabela
era invisível para esta operação. Agora a linha inmapeável é PULADA, com o log que já existia — o
raio de dano volta ao que era.

O BANCO SÓ COMPLETAVA, NUNCA CORTAVA

`padStart` não trunca: `'0237'` continuava `'0237'` e não batia com `'237'`. A borda aceita os dois
(`z.string().min(1).max(10)`), e um extrato legado de largura fixa grava exatamente assim. Era a
única das quatro partes que não tirava zeros à esquerda — a assimetria era o defeito.

A CONTA CONCATENAVA O DV, IGUAL À AGÊNCIA

`digitsOnly('0088123-3')` devolve `'00881233'` — conta e dígito colados. É o MESMO defeito que eu
tinha acabado de corrigir na agência, três linhas acima, e a assimetria estava escrita no próprio
arquivo sem que eu visse.

A conta passa a usar a mesma decomposição, e o DV é RECUPERADO do número quando a coluna própria
está vazia — que é o caso do ETL. Com a coluna preenchida, ela manda: é a fonte mais recente, e
deixar o embutido vencer faria uma correção pela tela não surtir efeito.

O CABEÇALHO PROMETIA MAIS DO QUE O CÓDIGO ENTREGA

A tabela do caso de produção listava `accountDigit '' vs '3'` entre as colunas unificadas. Só é
verdade quando o DV está embutido no número. Coluna vazia SEM nada embutido continua diferente de
dígito preenchido — e é o certo: colapsar "sem dígito" com "qualquer dígito" faria contas de dígitos
distintos virarem a mesma. O texto agora diz o que a régua faz e o que não faz, e nomeia a saída
(o operador informa o dígito pela tela).

A LACUNA DE COBERTURA

Os dois casos novos viviam só na borda, que roda no fake in-memory — o caminho DRIZZLE, justamente
onde a comparação deixou de ser `where` e virou varredura, ficava sem cobertura. O par foi para o
contrato compartilhado, que roda também contra MySQL real.

Um achado da revisão eu NÃO tratei, por discordar em parte: ela afirma que o diff removeu o backstop
da UNIQUE crua. Não removeu — a UNIQUE segue intacta e continua pegando escritas IDÊNTICAS. O que
nunca teve backstop é a colisão canônica com escritas diferentes, e isso é a pendência já declarada
no commit anterior (sem UNIQUE canônica até o cadastro estar saneado). O TOCTOU do check-then-insert
também é anterior a este PR.

Gate: typecheck + format:check + lint verdes; 11785 testes, 0 falhas (baseline 11780).
Integração financial contra MySQL 8.4 real: 213/213.

Refs: #995, #856, #708
Assisted-by: claude-code:claude-opus-5
Claude-Session: https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg
…a-995

fix(financial): a chave natural da conta-cedente ganha forma canônica (#995, bloco A)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant