Repository navigation
fix(financial): CNPJ alfanumérico deixa de virar outra inscrição em silêncio (#863) — traslado do #983 - #1010
Merged
Conversation
…ilêncio (#863) `digits()` existe para tirar MÁSCARA — `12.345.678/0001-99` e `12345678000199` são o mesmo CNPJ, e converter um no outro é tradução de formato. Aplicada a um documento ALFANUMÉRICO, ela deixava de tirar pontuação e passava a destruir conteúdo: digits('12ABC34501DE35', 14) -> '00000123450135' Catorze dígitos, sintaticamente perfeitos, e de outra pessoa. O arquivo era aceito, o pagamento saía, e o favorecido chegava ao banco identificado por um documento que não é o dele. Nada no caminho reclamava: o `remittance-inspector` aprova, o validador do banco aprova, e o erro só apareceria na conciliação ou numa contestação. A Receita emite CNPJ com letras desde 07/2026 (ADR-0044). O layout do Bradesco na v08 (jul/2025) é anterior e declara o campo `Num` — ele não acompanhou. O LAUDO DE 05/09 AGRAVOU ISTO, sem falar dele Ficou estabelecido que no Pix o PSP do recebedor cruza a inscrição do arquivo com o titular da chave no DICT, recusando com `PF` quando não bate. Uma inscrição deformada deixou de ser só um dado errado: é a recusa do pagamento, sem que nada no ERP aponte a causa. O DEFEITO ERA USAR O HELPER ERRADO `digits()` continua certo para agência, conta e CEP — ali o que ela remove é máscara, e máscara não é conteúdo. O que faltava era um campo que soubesse que inscrição é outra coisa. Entra `inscription()`, em `positional.ts`, e os SETE pontos de escrita passam a usá-lo: Segmento B da transferência e do Pix (019-032), o `G031` do Segmento A, o par sacado/cedente do Segmento J-52 (021-035 e 077-091) e os dois headers do cedente.⚠️ A carve-out do VAZIO é obrigatória, e um teste do Segmento J-52 a cobrou: sem ela, campo em branco passaria a sair como `inscription-alphanumeric-unsupported`, mandando escalar ao banco um caso que é simplesmente cadastro faltando. Vazio e estouro continuam sendo o que sempre foram. RECUSAR, E NÃO EMITIR — porque a pergunta ao banco está aberta As três saídas foram consideradas. Emitir com as letras num campo `Num` produz arquivo que o banco pode recusar depois do NSA queimado. Remover as letras é o defeito. Converter (o `ASCII − 48` da RFB) não é escolha que se faça sozinho: aquela regra é para CALCULAR o dígito verificador, não para transmitir, e usá-la aqui inventaria um formato que o banco não pediu. A pergunta — o Bradesco aceita CNPJ alfanumérico no CNAB 240, e em que forma? — está registrada na #863 e segue sem resposta. Enquanto isso, recusar com nome próprio é a única saída que não inventa layout nem paga errado. A CONTRAPARTE NO PRÉ-VOO, FORA DO SWITCH DE ROTA `readAlphanumericInscription` é a única régua do arquivo verificada fora do `switch`, e é deliberado: ela não é propriedade da rota. TODA rota com emissor escreve a inscrição — são sete pontos de escrita em três rotas. Dentro do `switch` seriam três cópias, e uma rota nova nasceria sem ela. O motivo é `unmappable`, não `malformed`: o CNPJ com letras está BEM FORMADO, e mandar "corrigir o formato" mandaria o operador estragar uma inscrição correta.⚠️ É a única lacuna do módulo cuja ação não é "vá ao cadastro" — é ESCALAR. Por isso o erro sobe até a application com nome próprio e converge para `remittance-build-failed`, e não para `remittance-payments-unavailable`, que diria ao operador "confira o pré-voo" quando não há nada lá que ele possa consertar. A FIXTURE QUE PRECISOU MUDAR, E É UM ACHADO `PAYEE_DOCUMENT` era `'inscricao-opaca'`, com razão escrita: provar que a régua "só pergunta se HÁ inscrição". A afirmação deixou de ser inteiramente verdadeira, e as quatro suítes que a usavam quebraram — corretamente. A distinção que sobrevive é fina e ficou registrada na fixture: a régua continua NÃO validando CPF/CNPJ (não confere dígito nem comprimento — há teste fixando que `'123'` passa). Ela só recusa o que `digits()` destruiria. Um valor sem nenhum dígito não distinguia os dois casos: falhava pelas duas razões ao mesmo tempo. Gate: typecheck + format:check + lint + 11692 testes, 0 falhas.⚠️ Empilhado sobre o PR #982 (que está sobre o #981).⚠️ NÃO fecha a #863: o CA1 admite as duas saídas, e esta é a defensiva. A issue fecha quando o banco responder como quer receber a inscrição alfanumérica. Refs: #863, #948 (CA11), #891, #856 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
…ção vazia (#863) Três achados da revisão do #983. 1. Documento só com pontuação divergia entre as duas pontas. `'---'`, `'.'` e `'./-'` sobrevivem ao `trim()` e normalizam para vazio: o emissor sempre os viu como campo sem conteúdo (`numeric-field-invalid`), enquanto o pré-voo os via como inscrição PRESENTE e — não sendo numéricos — concluía "alfanumérica", mandando ESCALAR ao banco um cadastro que só está incompleto. É a classe de divergência que a #837 fechou, e custa mais aqui do que nas outras: `unmappable` é a única lacuna do módulo cuja ação não é "vá ao cadastro". `hasInscription` passa a ser a pergunta única de presença, e o caso vira `missing` — a mesma ação do campo em branco. Só o boleto tinha régua de presença (`readBilletPayee`), então sem esta linha o caso passaria do pré-voo direto ao emissor na TED, com o NSA já queimado. 2. O reader mantinha `cleanDocument`, cópia idêntica de `normalizeInscription` — a mesma configuração que produziu a #863: duas decisões sobre o mesmo dado, nenhuma citando a outra, concordando por acidente até o dia em que uma mudasse. Passa a importar a do domínio, e o acoplamento fica declarado: `documentTypeOf` mede o COMPRIMENTO do que a normalização devolve, então qualquer mudança nela reclassifica pessoa física e jurídica no arquivo. 3. Comentários. Dois ainda citavam `digits()` para inscrição, que deixou de ser verdade. E o de `readAlphanumericInscription` prometia cobrir "sete pontos de escrita": a régua lê só `candidate.payee.document`, então são QUATRO. Os outros três — header de arquivo, header de lote e sacado do J-52 — escrevem a inscrição da própria empresa, que não chega num `PayoutCandidate`. Risco baixo, porque a empresa já existe e sua inscrição é numérica, mas o comentário prometia cobertura que não existe. Gate: typecheck, lint e format:check verdes; 11695 testes, 0 falhas. Assisted-by: Claude-Code:claude-opus-5 Claude-Session: https://claude.ai/code/session_01YZHvfYtCefkh7AUNwxnYaP
… o emissor ganha a rede que faltava Os dois achados do code-review desta branch. **1. Dado de cadastro em fixture (anti-padrão #9).** O documento que seis arquivos de teste usavam como "sintético" não era: tinha DV válido e correspondia à inscrição raiz de uma instituição financeira existente. Os repositórios são públicos, e o gate mecânico não alcança este caso — `bank-fixture-masking.test.ts` cobre convênio, e a rule do CNAB registra que "o resto depende de julgamento". Trocado pelo exemplo que o próprio repositório já consagra em `.claude/rules/domain.md`, sem correspondência a inscrição emitida e já usado em testes de `contracts`, `etl` e `jobs`. Nada dependia de o valor ser um CNPJ existente: `isCnabEmittableInscription` só exige dígitos.⚠️ O mesmo valor sobrevive em três arquivos FORA desta branch (um em `financial`, dois em `partners`). Não entraram aqui para não misturar módulos num PR de CNAB. **2. O teste prometia uma rede de regressão que não existia.** O comentário afirmava que, se alguém trocasse `inscription` de volta por `digits`, o caso falharia "apontando exatamente o que voltaria a acontecer". Não falharia: o arquivo chama `inscription()` DIRETO e não monta registro nenhum — ele prova que a régua existe, não que o emissor a usa. É a distinção entre USO e MENÇÃO, que é como o defeito original sobreviveu. E o teste de ponta a ponta não cobre a lacuna: em `generate-remittance.test.ts` (CA3) a recusa é do PRÉ-VOO, que barra antes de o emissor ser chamado. A camada que de fato produziria o arquivo aceito com a inscrição de outro não tinha teste executável em lugar nenhum. Acrescentados quatro casos que montam os registros de verdade — `fileHeader`, `batchHeader`, `segmentB` e `segmentBPix` — com documento alfanumérico, exigindo `inscription-alphanumeric-unsupported`. Por registro, e não por varredura de texto no fonte: o que precisa ser verdade é comportamento, e um grep acusaria também quem só menciona o nome. **Prova invertida (medida, não suposta):** revertendo os dois call sites de `multipag-records.ts` para `digits()`, os 8 casos que já existiam passam TODOS — inclusive o que prometia falhar — e só os casos novos acusam. É a demonstração de que a rede não existia. Gate verde: typecheck + format:check + lint + test (11.812 testes, 0 falhas). Refs: #863 Assisted-by: Claude-Code:claude-opus-5
This was referenced Sep 10, 2026
9 tasks
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.
Substitui o #983, que não é mergeável por um motivo que não se resolve resolvendo conflito.
Por que uma branch nova, e não o merge da
devno #983A
fix/inscription-alphanumeric-863tinha 11 commits fora dadev, e só 2 eram dela. Os outros 8 eram a leva da #948, que já entrara nadevpelo PR #1004 — com SHAs diferentes:dev(via #1004)11029d8eCA472c485ccCA4b7ef760cCA522b75f1dCA5a44f7d75CA622265b42CA6Git não reconhece os pares como o mesmo trabalho, então tratava cada arquivo como duas criações independentes — daí os dois
CONFLICT (add/add), eminscription.tsepreflight-covers-emitter-refusals.test.ts, que não têm por que existir num PR de duas correções. Mergear adevlá dentro resolveria os conflitos e deixaria os 8 commits duplicados no histórico para sempre.Aqui estão só os 2 commits da #863, aplicados sobre a
devatual, com autoria preservada.O que a resolução decidiu, onde os dois lados discordavam
cnab-remittance-translator.ts— os dois lados acrescentavam um membro à mesma união. Ficaram os dois:cnab-pix-requires-exclusive-file([financial] pré-voo do Pix aprova o que o emissor recusa — três recusas novas sem contraparte, e a partição derruba as outras modalidades #948 CA4) ecnab-inscription-alphanumeric-unsupported([financial] CNPJ alfanumérico do FAVORECIDO é destruído por digits() — o Segmento B emite outra inscrição #863).inscription.ts(add/add) — o lado dadevé superset em código: teminscriptionType/CPF_LENGTH, que a branch não tinha porque a [financial] header CNAB: 3 campos do cedente seguem hardcoded (documentType, agencyDigit, accountAgencyDigit) — bankName já saiu #856 os subiu ao domínio depois. Ficou o dadev, acrescido do único fato que só a branch trazia: o laudo do Bradesco de 05/09/2026 sobre o PSP cruzar a inscrição com o titular da chave no DICT.remittance-payment-reader.drizzle.ts— mesma razão invertida: a branch ainda mantinhacleanDocument/documentTypeOflocais, que adevjá eliminara em favor da função do domínio. Ficou o dadev.generate-remittance.ts— oswitchdoplanFilesnão tinha ramo para a variante nova e por isso deixou de ser exaustivo, o que tirou o narrowing deplan90 linhas adiante. Entrou no grupo doremittance-build-failed, que aqui já é o desfecho honesto — ao contrário doswitchdo montador, onde o genérico éremittance-payments-unavailablee ela precisa sair do grupo.preflight-covers-emitter-refusals.test.ts— a entrada da [financial] CNPJ alfanumérico do FAVORECIDO é destruído por digits() — o Segmento B emite outra inscrição #863 voltou aoRecord, com a sonda original, e o piso de sondas voltou de 3 para 4. Era o que o842a31b0reservara nadev.842a31b0descreve remoções que o próprio diff dele não contém (ele altera 2 linhas de comentário). O conteúdo já saíra antes; a mensagem descreve o efeito acumulado da branch, não o commit. Não bloqueia nada, mas quem procurar a remoção por aquele SHA não a encontra.Gate
typecheck+format:check+lintverdes. 11.825 testes, 0 falhas, 20 skips (os esperados). Rodado por commit, pelo hook de pre-commit.Revisão — 4 achados, todos anteriores a este traslado
Nenhum vem da resolução de conflito; são do código original da #863 e ficam para a autora decidir.
payout-readiness.ts:279— média. O carve-out do vazio (document.trim() === '' → null) delega a cobrança areadBilletPayee, que só existe na rotabillet. Na TED o resultado é assimétrico:document: '---'devolvemissing, masdocument: null/''continua devolvendoready— o pré-voo aprova, o NSA é alocado, esegmentBfalha comcnab-translation-failedsem apontar campo. As duas formas de "não há inscrição" deveriam dar o mesmo veredito.payout-readiness.ts:324— baixa/média.readAlphanumericInscriptionroda para todas as rotas,tax-guideinclusive, embora nenhum registro de guia escreva a inscrição do favorecido. O status muda deno-issuerparaincomplete, e o operador lê "cadastro incompleto" onde a verdade é "esta forma ainda não é emitida".payout-readiness.ts:253— baixa (registro mente sobre o código). O comentário afirma que a inscrição do cedente "não chega aqui" e que uma alfanumérica derrubaria a remessa depois doallocateNsa.checkCedenteInscriptionjá a recusa antes, emgenerate-remittance.ts:145.remittance-eligibility.ts:156— baixa. O comentário diz "a pergunta éhasInscription", mas o código escrevenormalizeInscription(...) === ''à mão. ComhasInscriptionjá existindo, este é o terceiro lugar com a definição de "vazio" duplicada — a configuração que a própria [financial] CNPJ alfanumérico do FAVORECIDO é destruído por digits() — o Segmento B emite outra inscrição #863 documenta como causa.Fora do código:
00000000000191é descrito como "sintético" em três fixtures, mas é CNPJ real com DV válido. Os repositórios são públicos; um valor com DV deliberadamente inválido serviria igual sem afirmar algo falso no comentário.Closes #863
Refs #983, #948, #1004