Repository navigation
release(financial): NSA por convênio, Pix por chave e o ciclo de vida da conta-cedente - #1002
Merged
Merged
Conversation
…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
…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)
This was referenced Sep 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Leva da
devpara amain: 22 commits, 7 PRs, 63 arquivos (+12.567 / −1.047). Tudo emfin_*— nenhum outro módulo é tocado.O que entra
O que importa para o deploy
0056_daily_firebrand,0057_lying_vindicator,0058_melodic_nick_fury(com snapshots e_journal.json). A fix(financial): a sequência de NSA passa a ser do convênio — destrava a conta irmã em produção (#943) #997 depende do backfill da 0057, que é idempotente.scripts/financial/build-ispb-map.ts,src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts,src/modules/financial/adapters/cnab/payee-ispb.tse o scriptcnab:ispbdopackage.json— o mapa ISPB gerado sai junto com a decisão de zerar o ISPB ([partners] ISPB do favorecido não existe no cadastro — bloqueia o Segmento A e o B do emissor PIX #923, [financial] Pix por chave não deve exigir bloco bancário do favorecido — e falta a regra de preenchimento do Segmento A #945).cedente/natural-key.ts,cedente/nsa-sequence.ts,payout/inscription.ts,payout/pix-key.ts.delete-cedente-account,reopen-cedente-account.Estado
mainestá 2 commits à frente, mas são só os merges das PRs chore(release): promove a 1.0.0-rc.2 para a main — deploy de produção #886 e Fix migration issues for convenio and improve ETL handling #978:git diff origin/dev...origin/mainé vazio. Não há conteúdo namainque adevnão tenha.dev(1a5dedee).dev:typecheck,format:checkelintsem saída;pnpm testcomfail 0— 11785 testes, 11765 pass, 20 skipped, 0 todo.Fora desta PR
A dependabot #973 (
mysql23.22.3 → 3.23.1) aponta direto para amainem vez dadev. Decisão separada: redirecionar para adevou fechar.https://claude.ai/code/session_01XNaURHGErqVMsDMAMDThyg