From a6098483c302d8982406d8603d762e1cea9999e2 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sat, 5 Sep 2026 11:52:56 -0300 Subject: [PATCH 01/13] fix(financial): o Pix por chave para de exigir conta, e o ISPB vira zeros (#923, #945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01NJKkFN7sQDoBS8AvteK1kZ --- package.json | 1 - scripts/financial/build-ispb-map.ts | 166 -------- .../cnab/bradesco-multipag-translator.ts | 6 +- .../cnab/ispb-by-bank-code.generated.ts | 362 ------------------ .../adapters/cnab/multipag-segments.ts | 95 ++++- .../financial/adapters/cnab/payee-ispb.ts | 40 -- .../financial/adapters/cnab/pix-initiation.ts | 13 +- .../adapters/cnab/remittance-file.ts | 69 ++-- .../remittance-payment-reader.drizzle.ts | 40 +- .../ports/cnab-remittance-translator.ts | 52 ++- .../use-cases/generate-remittance.ts | 15 +- .../domain/payout/payout-readiness.ts | 57 +-- .../cnab/multipag-segment-b-pix.test.ts | 1 - .../adapters/cnab/payee-ispb.test.ts | 82 ---- .../adapters/cnab/remittance-file.test.ts | 80 ++-- .../use-cases/preview-remittance.test.ts | 11 +- .../domain/payout/payout-readiness.test.ts | 46 ++- 17 files changed, 306 insertions(+), 830 deletions(-) delete mode 100644 scripts/financial/build-ispb-map.ts delete mode 100644 src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts delete mode 100644 src/modules/financial/adapters/cnab/payee-ispb.ts delete mode 100644 tests/modules/financial/adapters/cnab/payee-ispb.test.ts diff --git a/package.json b/package.json index 66306458..ee7c71d7 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "docs:links": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/handbook/link-scan.ts", "wiki:sync": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/handbook/wiki-sync.ts", "cnab:index": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/financial/cnab-manual-index.ts", - "cnab:ispb": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/financial/build-ispb-map.ts", "logbook": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/claude/logbook.ts", "release:notes": "node --experimental-strip-types --enable-source-maps --no-warnings scripts/ci/release-notes.ts", "audit": "pnpm audit --prod --audit-level=high", diff --git a/scripts/financial/build-ispb-map.ts b/scripts/financial/build-ispb-map.ts deleted file mode 100644 index 9569ca19..00000000 --- a/scripts/financial/build-ispb-map.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Gera o mapa `código de compensação → ISPB` a partir da lista de participantes do STR publicada - * pelo Banco Central (#923). - * - * pnpm run cnab:ispb - * - * ## Por que um MÓDULO TS, e não um `.db` como a tabela de domínios - * - * O `dominios-spb.db` (`.claude/skills/cnab240-bradesco/dominios/`) é consultado pelo HARNESS — um - * agente perguntando "este código ainda é vigente?", em Bun, fora do caminho de execução. Este mapa é - * consultado em RUNTIME DE PRODUÇÃO, dentro da geração do arquivo de pagamento, onde `src/` roda em - * Node e o `node:sqlite` ainda é experimental. Depender dele ali seria pôr um módulo experimental no - * caminho que move dinheiro, para ler 347 pares que cabem em 12 KB. - * - * O módulo gerado tem três propriedades que o `.db` não tem aqui: zero I/O na emissão, cobertura do - * `typecheck`, e **diff legível na PR** — quem revisar a atualização vê exatamente quais instituições - * entraram e saíram, em vez de um binário que mudou. - * - * ## Determinismo e proveniência - * - * Mesma entrada → mesma saída, byte a byte: as chaves saem ordenadas. O cabeçalho do arquivo gerado - * grava o nome da fonte, o **sha256** e as contagens, para que uma afirmação sobre o ISPB possa citar - * de onde veio — não da memória de quem escreveu. É a mesma disciplina da tabela `meta` do - * `dominios-spb.db`. - * - * ## ⚠️ A coluna que NÃO se usa para filtrar - * - * `Participa_da_Compe` responde se a instituição opera na câmara de compensação (cheque, DOC) — NÃO - * se ela tem código, e não se ela faz Pix. Medido em 01/09/2026: dos 347 participantes com código - * numérico, apenas **95** participam da Compe. Nubank (`260`) e C6 (`336`) estão entre os 252 que não - * participam — e são favorecidos correntes. Filtrar por essa coluna produziria "ISPB desconhecido" - * para cadastro correto, e o operador seria mandado consertar o que já está certo. - */ -import { readFileSync, writeFileSync } from 'node:fs'; -import { createHash } from 'node:crypto'; -import { basename, dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(HERE, '..', '..'); -const SOURCE_CSV = join(ROOT, '.claude/skills/cnab240-bradesco/participantes-str-20260901.csv'); -const TARGET_TS = join(ROOT, 'src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts'); - -const COLUMN = { ISPB: 0, SHORT_NAME: 1, BANK_CODE: 2 } as const; - -/** - * Parser de linha CSV que respeita aspas — e não é zelo teórico: `Nome_Extenso` traz vírgula DENTRO - * do campo em 33 linhas ("SANTINVEST S.A. - CREDITO, FINANCIAMENTO E INVESTIMENTO"). Um `split(',')` - * ingênuo desloca as colunas seguintes e produz mapa errado sem erro algum. - */ -const parseCsvLine = (line: string): readonly string[] => { - const fields: string[] = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i += 1) { - const char = line[i]; - if (char === '"') { - // `""` dentro de campo entre aspas é uma aspa literal. - if (inQuotes && line[i + 1] === '"') { - current += '"'; - i += 1; - } else { - inQuotes = !inQuotes; - } - } else if (char === ',' && !inQuotes) { - fields.push(current); - current = ''; - } else { - // `?? ''` por `noUncheckedIndexedAccess`: o índice é sempre válido dentro do laço, e o - // compilador não tem como saber disso. - current += char ?? ''; - } - } - fields.push(current); - return fields; -}; - -type Participant = Readonly<{ bankCode: string; ispb: string; name: string }>; - -// U+FEFF, o BOM UTF-8 que o arquivo do Bacen traz. Construído por código, e não escrito literalmente: -// o caractere no fonte é "irregular whitespace" para o ESLint — e um caractere invisível num literal é -// a última coisa que alguém procura ao depurar. -const BOM = String.fromCodePoint(0xfeff); - -const readParticipants = (csv: string): readonly Participant[] => { - // ⚠️ Sem remover o BOM, o primeiro cabeçalho não é `ISPB` mas `ISPB`, e a checagem de layout - // abaixo falharia por um caractere que não aparece na tela. As colunas são lidas por índice, então - // o resto do parse continuaria "funcionando" — com a guarda desarmada. - const withoutBom = csv.startsWith(BOM) ? csv.slice(BOM.length) : csv; - const lines = withoutBom.split(/\r?\n/); - const [header, ...rows] = lines; - if (header === undefined) throw new Error('CSV vazio'); - - const headerFields = parseCsvLine(header); - if (headerFields[COLUMN.ISPB] !== 'ISPB' || headerFields[COLUMN.BANK_CODE] !== 'Número_Código') { - throw new Error( - `layout do CSV mudou — esperado ISPB na coluna 0 e Número_Código na 2, veio ${JSON.stringify(headerFields.slice(0, 3))}`, - ); - } - - const participants: Participant[] = []; - for (const row of rows) { - if (row.trim() === '') continue; - const fields = parseCsvLine(row); - const ispb = (fields[COLUMN.ISPB] ?? '').trim(); - const bankCode = (fields[COLUMN.BANK_CODE] ?? '').trim(); - const name = (fields[COLUMN.SHORT_NAME] ?? '').trim(); - - // `n/a` é participante sem código de compensação — Selic, câmaras, o próprio Bacen. Não entra no - // mapa porque ninguém o digita como banco do favorecido, e inventar uma chave para ele criaria - // uma entrada que só pode ser alcançada por engano. - if (!/^\d{3}$/.test(bankCode)) continue; - if (!/^\d{8}$/.test(ispb)) throw new Error(`ISPB fora do formato para ${bankCode}: ${ispb}`); - - participants.push({ bankCode, ispb, name }); - } - return participants; -}; - -const main = (): void => { - const raw = readFileSync(SOURCE_CSV); - const sha256 = createHash('sha256').update(raw).digest('hex'); - const participants = readParticipants(raw.toString('utf8')); - - const byCode = new Map(); - for (const p of participants) { - const existing = byCode.get(p.bankCode); - // Unicidade MEDIDA na fonte de 01/09/2026 (zero duplicatas em 347). A guarda existe porque a - // premissa é da EDIÇÃO, não do formato: o dia em que o Bacen publicar dois ISPB para o mesmo - // código, o build precisa parar — e não eleger um dos dois por ordem de leitura. - if (existing !== undefined && existing.ispb !== p.ispb) { - throw new Error( - `código ${p.bankCode} aparece com dois ISPB distintos — a de-para deixou de ser determinística`, - ); - } - byCode.set(p.bankCode, p); - } - - const sorted = [...byCode.values()].sort((a, b) => a.bankCode.localeCompare(b.bankCode)); - const entries = sorted.map((p) => ` '${p.bankCode}': '${p.ispb}', // ${p.name}`).join('\n'); - - const content = `// GERADO POR \`pnpm run cnab:ispb\` — NÃO EDITAR À MÃO. -// -// Mapa código de compensação → ISPB, para o campo \`P015\` do Segmento B na modalidade Pix (#923). -// -// Fonte: ${basename(SOURCE_CSV)} — Relação de Participantes do STR, Banco Central do Brasil. -// sha256: ${sha256} -// participantes no arquivo: ${String(participants.length)} com código numérico -// entradas neste mapa: ${String(sorted.length)} -// -// ⚠️ Instituição entra e sai do SPI. Um ISPB extinto produz arquivo BEM-FORMADO que o banco recusa, e -// o \`remittance-inspector.ts\` não pega — não é defeito de forma. Regerar ao atualizar a fonte. - -export const ISPB_BY_BANK_CODE: Readonly> = { -${entries} -}; -`; - - writeFileSync(TARGET_TS, content, 'utf8'); - process.stdout.write( - `mapa gerado: ${String(sorted.length)} entradas\n fonte: ${basename(SOURCE_CSV)}\n sha256: ${sha256}\n`, - ); -}; - -main(); diff --git a/src/modules/financial/adapters/cnab/bradesco-multipag-translator.ts b/src/modules/financial/adapters/cnab/bradesco-multipag-translator.ts index a202368e..e4bfdb28 100644 --- a/src/modules/financial/adapters/cnab/bradesco-multipag-translator.ts +++ b/src/modules/financial/adapters/cnab/bradesco-multipag-translator.ts @@ -46,10 +46,8 @@ const translateErrorFor = (error: RemittanceFileError): CnabTranslateError => { // no campo — não "falhou a tradução" (#838). case 'pix-key-unrepresentable': return 'cnab-pix-key-unrepresentable'; - // Os dois erros de TRADUÇÃO da rota Pix. Sobem nomeados pela mesma régua: um manda ao cadastro - // (ou à fonte do Bacen), o outro diz que o tipo da chave não existe no domínio do layout. - case 'payee-ispb-unknown': - return 'cnab-payee-ispb-unknown'; + // O erro de TRADUÇÃO da rota Pix, que sobe nomeado: diz que o tipo da chave não existe no + // domínio do layout. Eram dois até a #923 — o `payee-ispb-unknown` saiu com a tabela de-para. case 'remittance-pix-key-type-unsupported': return 'cnab-pix-key-type-unsupported'; // `remittance-mixed-file-modalities` converge para o desfecho genérico, e é escolha, não diff --git a/src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts b/src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts deleted file mode 100644 index 445d9bdd..00000000 --- a/src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts +++ /dev/null @@ -1,362 +0,0 @@ -// GERADO POR `pnpm run cnab:ispb` — NÃO EDITAR À MÃO. -// -// Mapa código de compensação → ISPB, para o campo `P015` do Segmento B na modalidade Pix (#923). -// -// Fonte: participantes-str-20260901.csv — Relação de Participantes do STR, Banco Central do Brasil. -// sha256: 841bdf63b0ca306803e8d720c2932c8c922ff3330753c31d176cd16d5d808fd3 -// participantes no arquivo: 348 com código numérico -// entradas neste mapa: 348 -// -// ⚠️ Instituição entra e sai do SPI. Um ISPB extinto produz arquivo BEM-FORMADO que o banco recusa, e -// o `remittance-inspector.ts` não pega — não é defeito de forma. Regerar ao atualizar a fonte. - -export const ISPB_BY_BANK_CODE: Readonly> = { - '001': '00000000', // BCO DO BRASIL S.A. - '003': '04902979', // BCO DA AMAZONIA S.A. - '004': '07237373', // BCO DO NORDESTE DO BRASIL S.A. - '007': '33657248', // BNDES - '010': '81723108', // CREDICOAMO - '011': '61809182', // C.SUISSE HEDGING-GRIFFO CV S/A - '012': '04866275', // BANCO INBURSA - '014': '09274232', // STATE STREET BR S.A. BCO COMERCIAL - '015': '02819125', // UBS BRASIL CCTVM S.A. - '016': '04715685', // CCM DESP TRÂNS SC E RS - '017': '42272526', // BNY MELLON BCO S.A. - '018': '57839805', // BCO TRICURY S.A. - '021': '28127603', // BCO BANESTES S.A. - '024': '10866788', // BCO BANDEPE S.A. - '025': '03323840', // BCO ALFA S.A. - '029': '33885724', // BANCO ITAÚ CONSIGNADO S.A. - '033': '90400888', // BCO SANTANDER (BRASIL) S.A. - '036': '06271464', // BCO BBI S.A. - '037': '04913711', // BCO DO EST. DO PA S.A. - '040': '03609817', // BCO CARGILL S.A. - '041': '92702067', // BCO DO ESTADO DO RS S.A. - '047': '13009717', // BCO DO EST. DE SE S.A. - '060': '04913129', // CONFIDENCE CC S.A. - '062': '03012230', // HIPERCARD BM S.A. - '063': '04184779', // BANCO BRADESCARD - '064': '04332281', // GOLDMAN SACHS DO BRASIL BM S.A - '065': '48795256', // BCO ANDBANK S.A. - '066': '02801938', // BCO MORGAN STANLEY S.A. - '069': '61033106', // BCO CREFISA S.A. - '070': '00000208', // BRB - BCO DE BRASILIA S.A. - '074': '03017677', // BCO. J.SAFRA S.A. - '075': '03532415', // BCO ABN AMRO S.A. - '076': '07656500', // BCO KDB BRASIL S.A. - '077': '00416968', // BANCO INTER - '078': '34111187', // HAITONG BI DO BRASIL S.A. - '079': '09516419', // PICPAY BANK - BANCO MÚLTIPLO S.A - '080': '73622748', // B&T CC LTDA. - '081': '10264663', // BANCOSEGURO S.A. - '082': '07679404', // BANCO TOPÁZIO S.A. - '083': '10690848', // BCO DA CHINA BRASIL S.A. - '084': '02398976', // SISPRIME DO BRASIL - COOP - '085': '05463212', // COOPCENTRAL AILOS - '088': '11476673', // BANCO RANDON S.A. - '089': '62109566', // CREDISAN CC - '093': '07945233', // POLOCRED SCMEPP LTDA. - '094': '11758741', // BANCO FINAXIS - '095': '11703662', // TRAVELEX BANCO DE CÂMBIO S.A. - '096': '00997185', // BCO B3 S.A. - '097': '04632856', // CREDISIS CENTRAL DE COOPERATIVAS DE CRÉDITO LTDA. - '098': '78157146', // CREDIALIANÇA CCR - '099': '03046391', // UNIPRIME COOPCENTRAL LTDA. - '100': '00806535', // PLANNER CV S.A. - '101': '62287735', // RENASCENCA DTVM LTDA - '102': '02332886', // XP INVESTIMENTOS CCTVM S/A - '104': '00360305', // CAIXA ECONOMICA FEDERAL - '105': '07652226', // LECCA CFI S.A. - '107': '15114366', // BCO BOCOM BBM S.A. - '111': '36113876', // OLIVEIRA TRUST DTVM S.A. - '113': '61723847', // NEON CTVM S.A. - '114': '05790149', // CENTRAL COOPERATIVA DE CRÉDITO NO ESTADO DO ESPÍRITO SANTO - '117': '92856905', // ADVANCED CC LTDA - '119': '13720915', // BCO WESTERN UNION - '120': '33603457', // BCO RODOBENS S.A. - '121': '10664513', // BCO AGIBANK S.A. - '122': '33147315', // BCO BRADESCO BERJ S.A. - '124': '15357060', // BCO WOORI BANK DO BRASIL S.A. - '125': '45246410', // BANCO GENIAL - '126': '13220493', // BR PARTNERS BI - '127': '09512542', // CODEPE CVC S.A. - '128': '19307785', // BRAZA BANK S.A. BCO DE CÂMBIO - '129': '18520834', // UBS BRASIL BI S.A. - '130': '09313766', // CARUANA SCFI - '131': '61747085', // TULLETT PREBON BRASIL CVC LTDA - '132': '17453575', // ICBC DO BRASIL BM S.A. - '133': '10398952', // CRESOL CONFEDERAÇÃO - '134': '33862244', // BGC LIQUIDEZ DTVM LTDA - '136': '00315557', // CONF NAC COOP CENTRAIS UNICRED - '138': '10853017', // GET MONEY CC LTDA - '139': '55230916', // INTESA SANPAOLO BRASIL S.A. BM - '140': '62169875', // NU INVEST CORRETORA DE VALORES S.A. - '141': '09526594', // MASTER BI S.A. - '142': '16944141', // BROKER BRASIL CC LTDA. - '143': '02992317', // TREVISO CC S.A. - '144': '13059145', // BEXS BCO DE CAMBIO S.A. - '145': '50579044', // LEVYCAM CCV LTDA - '146': '24074692', // GUITTA CC LTDA - '149': '15581638', // FACTA S.A. CFI - '157': '09105360', // ICAP DO BRASIL CTVM LTDA. - '159': '05442029', // CASA CREDITO S.A. SCM - '173': '13486793', // BRL TRUST DTVM SA - '174': '43180355', // PEFISA S.A. - C.F.I. - '177': '65913436', // GUIDE - '180': '02685483', // CM CAPITAL MARKETS CCTVM LTDA - '183': '09210106', // SOCRED SA - SCMEPP - '184': '17298092', // BCO ITAÚ BBA S.A. - '188': '33775974', // ATIVA S.A. INVESTIMENTOS CCTVM - '189': '07512441', // HS FINANCEIRA - '190': '03973814', // SERVICOOP - '191': '04257795', // NOVA FUTURA CTVM LTDA. - '194': '20155248', // PARMETAL DTVM LTDA - '195': '07799277', // VALOR SCD S.A. - '196': '32648370', // FAIR CC S.A. - '197': '16501555', // STONE IP S.A. - '208': '30306294', // BANCO BTG PACTUAL S.A. - '212': '92894922', // BANCO ORIGINAL - '213': '54403563', // BCO ARBI S.A. - '217': '91884981', // BANCO JOHN DEERE S.A. - '218': '71027866', // BCO BS2 S.A. - '222': '75647891', // BCO CRÉDIT AGRICOLE BR S.A. - '224': '58616418', // BCO FIBRA S.A. - '233': '62421979', // BANCO CIFRA - '237': '60746948', // BCO BRADESCO S.A. - '241': '31597552', // BCO CLASSICO S.A. - '243': '33923798', // BANCO MASTER - '246': '28195667', // BCO ABC BRASIL S.A. - '249': '61182408', // BANCO INVESTCRED UNIBANCO S.A. - '250': '50585090', // BCV - BCO, CRÉDITO E VAREJO S.A. - '253': '52937216', // BEXS CC S.A. - '254': '14388334', // PARANA BCO S.A. - '259': '08609934', // MONEYCORP BCO DE CÂMBIO S.A. - '260': '18236120', // NU PAGAMENTOS - IP - '265': '33644196', // BCO FATOR S.A. - '266': '33132044', // BCO CEDULA S.A. - '268': '14511781', // BARI CIA HIPOTECÁRIA - '269': '53518684', // BCO HSBC S.A. - '270': '61444949', // SAGITUR CC - '271': '27842177', // IB CCTVM S.A. - '272': '00250699', // AGK CC S.A. - '273': '08253539', // CCR DE SÃO MIGUEL DO OESTE - '274': '11581339', // BMP SCMEPP LTDA - '276': '11970623', // BCO SENFF S.A. - '278': '27652684', // GENIAL INVESTIMENTOS CVM S.A. - '279': '26563270', // COOP DE PRIMAVERA DO LESTE - '280': '23862762', // WILL FINANCEIRA S.A.CFI - '281': '76461557', // CCR COOPAVEL - '283': '89960090', // RB INVESTIMENTOS DTVM LTDA. - '285': '71677850', // FRENTE CC S.A. - '288': '62237649', // CAROL DTVM LTDA. - '289': '94968518', // EFX CC LTDA. - '290': '08561701', // PAGSEGURO INTERNET IP S.A. - '292': '28650236', // BS2 DTVM S.A. - '293': '71590442', // LASTRO RDV DTVM LTDA - '296': '04062902', // OZ CORRETORA DE CÂMBIO S.A. - '298': '17772370', // VIPS CC LTDA. - '299': '04814563', // BCO AFINZ S.A. - BM - '300': '33042151', // BCO LA NACION ARGENTINA - '301': '13370835', // DOCK IP S.A. - '306': '40303299', // PORTOPAR DTVM LTDA - '307': '03751794', // TERRA INVESTIMENTOS DTVM - '309': '14190547', // CAMBIONET CC LTDA - '310': '22610500', // VORTX DTVM LTDA. - '311': '76641497', // DOURADA CORRETORA - '312': '07693858', // HSCM SCMEPP LTDA. - '313': '16927221', // AMAZÔNIA CC LTDA. - '318': '61186680', // BCO BMG S.A. - '319': '11495073', // OM DTVM LTDA - '320': '07450604', // BCO CCB BRASIL S.A. - '321': '18188384', // CREFAZ SCMEPP LTDA - '322': '01073966', // CCR DE ABELARDO LUZ - '323': '10573521', // MERCADO PAGO IP LTDA. - '324': '21332862', // CARTOS SCD S.A. - '325': '13293225', // ÓRAMA DTVM S.A. - '326': '03311443', // PARATI - CFI S.A. - '328': '05841967', // CECM FABRIC CALÇADOS SAPIRANGA - '329': '32402502', // QI SCD S.A. - '330': '00556603', // BANCO BARI S.A. - '331': '13673855', // FRAM CAPITAL DTVM S.A. - '332': '13140088', // ACESSO SOLUÇÕES DE PAGAMENTO S.A. - INSTITUIÇÃO DE PAGAMENTO - '334': '15124464', // BANCO BESA S.A. - '335': '27098060', // BANCO DIGIO - '336': '31872495', // BCO C6 S.A. - '340': '09554480', // SUPERDIGITAL I.P. S.A. - '341': '60701190', // ITAÚ UNIBANCO S.A. - '342': '32997490', // CREDITAS SCD - '343': '24537861', // FFA SCMEPP LTDA. - '348': '33264668', // BCO XP S.A. - '349': '27214112', // AL5 S.A. CFI - '350': '01330387', // CREHNOR LARANJEIRAS - '352': '29162769', // TORO CTVM S.A. - '355': '34335592', // ÓTIMO SCD S.A. - '358': '09464032', // MIDWAY S.A. - SCFI - '359': '05351887', // ZEMA CFI S/A - '360': '02276653', // TRINUS CAPITAL DTVM - '362': '01027058', // CIELO IP S.A. - '363': '62285390', // SINGULARE CTVM S.A. - '364': '09089356', // EFÍ S.A. - IP - '365': '68757681', // SIMPAUL - '366': '61533584', // BCO SOCIETE GENERALE BRASIL - '367': '34711571', // VITREO DTVM S.A. - '368': '08357240', // BCO CSF S.A. - '370': '61088183', // BCO MIZUHO S.A. - '371': '92875780', // WARREN CVMC LTDA - '373': '35977097', // UP.P SEP S.A. - '374': '27351731', // REALIZE CFI S.A. - '376': '33172537', // BCO J.P. MORGAN S.A. - '377': '17826860', // BMS SCD S.A. - '378': '01852137', // BCO BRASILEIRO DE CRÉDITO S.A. - '379': '01658426', // CECM COOPERFORTE - '380': '22896431', // PICPAY - '381': '60814191', // BCO MERCEDES-BENZ S.A. - '382': '04307598', // FIDUCIA SCMEPP LTDA - '383': '21018182', // EBANX IP LTDA. - '384': '11165756', // GLOBAL SCM LTDA - '385': '03844699', // CECM DOS TRAB.PORT. DA G.VITOR - '386': '30680829', // NU FINANCEIRA S.A. CFI - '387': '03215790', // BCO TOYOTA DO BRASIL S.A. - '389': '17184037', // BCO MERCANTIL DO BRASIL S.A. - '390': '59274605', // BCO GM S.A. - '391': '08240446', // CCR DE IBIAM - '393': '59109165', // BCO VOLKSWAGEN S.A - '394': '07207996', // BCO BRADESCO FINANC. S.A. - '395': '08673569', // F D GOLD DTVM LTDA - '396': '13884775', // HUB IP S.A. - '397': '34088029', // LISTO SCD S.A. - '398': '31749596', // IDEAL CTVM S.A. - '399': '01701201', // KIRTON BANK - '400': '05491616', // COOP CREDITAG - '401': '15111975', // IUGU IP S.A. - '402': '36947229', // COBUCCIO S.A. SCFI - '403': '37880206', // CORA SCD S.A. - '404': '37241230', // SUMUP SCD S.A. - '406': '37715993', // ACCREDITO SCD S.A. - '407': '00329598', // ÍNDIGO INVESTIMENTOS DTVM LTDA. - '408': '36586946', // BONUSPAGO SCD S.A. - '410': '05684234', // PLANNER SOCIEDADE DE CRÉDITO DIRETO - '411': '05192316', // VIA CERTA FINANCIADORA S.A. - CFI - '412': '15173776', // SOCIAL BANK S/A - '413': '01858774', // BCO BV S.A. - '414': '37526080', // LEND SCD S.A. - '416': '19324634', // LAMARA SCD S.A. - '418': '37414009', // ZIPDIN SCD S.A. - '419': '38129006', // NUMBRS SCD S.A. - '421': '39343350', // CC LAR CREDI - '422': '58160789', // BCO SAFRA S.A. - '423': '00460065', // COLUNA S.A. DTVM - '425': '03881423', // SOCINAL S.A. CFI - '426': '11285104', // NEON FINANCEIRA - CFI S.A. - '427': '27302181', // CRED-UFES - '428': '39664698', // CREDSYSTEM SCD S.A. - '429': '05676026', // CREDIARE CFI S.A. - '430': '00204963', // CCR SEARA - '433': '44077014', // BR-CAPITAL DTVM S.A. - '435': '38224857', // DELCRED SCD S.A. - '438': '67030395', // TRUSTEE DTVM LTDA. - '439': '16695922', // ID CTVM - '440': '82096447', // CREDIBRF COOP - '442': '87963450', // MAGNETIS - DTVM - '443': '39416705', // CREDIHOME SCD - '444': '40654622', // TRINUS SCD S.A. - '445': '35551187', // PLANTAE CFI - '447': '12392983', // MIRAE ASSET CCTVM LTDA - '448': '39669186', // HEMERA DTVM LTDA. - '449': '37555231', // DM - '450': '13203354', // FITBANK IP - '451': '40475846', // J17 - SCD S/A - '452': '39676772', // CREDIFIT SCD S.A. - '454': '41592532', // MÉRITO DTVM LTDA. - '455': '38429045', // FÊNIX DTVM LTDA. - '456': '60498557', // BCO MUFG BRASIL S.A. - '457': '39587424', // UY3 SCD S/A - '458': '07253654', // HEDGE INVESTMENTS DTVM LTDA. - '459': '04546162', // CCM SERV. PÚBLICOS SP - '460': '42047025', // UNAVANTI SCD S/A - '461': '19540550', // ASAAS IP S.A. - '462': '39908427', // STARK SCD S.A. - '463': '40434681', // AZUMI DTVM - '464': '60518222', // BCO SUMITOMO MITSUI BRASIL S.A. - '465': '40083667', // CAPITAL CONSIG SCD S.A. - '467': '33886862', // MASTER S/A CCTVM - '468': '04862600', // PORTOSEG S.A. CFI - '469': '07138049', // LIGA INVEST DTVM LTDA. - '470': '18394228', // CDC SCD S.A. - '471': '04831810', // CECM SERV PUBL PINHÃO - '473': '33466988', // BCO CAIXA GERAL BRASIL S.A. - '475': '10371492', // BCO YAMAHA MOTOR S.A. - '477': '33042953', // CITIBANK N.A. - '478': '11760553', // GAZINCRED S.A. SCFI - '479': '60394079', // BCO ITAUBANK S.A. - '481': '43599047', // SUPERLÓGICA SCD S.A. - '482': '42259084', // SBCASH SCD - '484': '36864992', // MAF DTVM SA - '487': '62331228', // DEUTSCHE BANK S.A.BCO ALEMAO - '488': '46518205', // JPMORGAN CHASE BANK - '495': '44189447', // BCO LA PROVINCIA B AIRES BCE - '505': '32062580', // BCO CREDIT SUISSE S.A. - '506': '42066258', // RJI - '507': '37229413', // SCFI EFÍ S.A. - '508': '61384004', // AVENUE SECURITIES DTVM LTDA. - '509': '13935893', // CELCOIN IP S.A. - '510': '39738065', // FFCRED SCD S.A. - '511': '44683140', // MAGNUM SCD - '512': '36266751', // FINVEST DTVM - '513': '44728700', // ATF CREDIT SCD S.A. - '516': '36583700', // QISTA S.A. CFI - '518': '37679449', // MERCADO CRÉDITO SCFI S.A. - '519': '40768766', // LIONS TRUST DTVM - '521': '44019481', // PEAK SEP S.A. - '522': '47593544', // RED SCD S.A. - '523': '44292580', // HR DIGITAL SCD - '524': '45854066', // WNT CAPITAL DTVM - '525': '34265629', // INTERCAM CC LTDA - '526': '46026562', // MONETARIE SCD - '527': '44478623', // ATICCA SCD S.A. - '528': '34829992', // REAG DTVM S.A. - '529': '17079937', // PINBANK IP - '530': '47873449', // SER FINANCE SCD S.A. - '532': '45745537', // EAGLE SCD S.A. - '534': '00714671', // EWALLY IP S.A. - '535': '39519944', // MARU SCD S.A. - '536': '20855875', // NEON PAGAMENTOS S.A. IP - '537': '45756448', // MICROCASH SCMEPP LTDA. - '539': '00122327', // SANTINVEST S.A. - CFI - '541': '00954288', // FDO GARANTIDOR CRÉDITOS - '545': '17352220', // SENSO CCVM S.A. - '546': '30980539', // U4C INSTITUIÇÃO DE PAGAMENTO S.A. - '600': '59118133', // BCO LUSO BRASILEIRO S.A. - '604': '31895683', // BCO INDUSTRIAL DO BRASIL S.A. - '610': '78626983', // BCO VR S.A. - '611': '61820817', // BCO PAULISTA S.A. - '612': '31880826', // BCO GUANABARA S.A. - '613': '60850229', // OMNI BANCO S.A. - '623': '59285411', // BANCO PAN - '626': '61348538', // BCO C6 CONSIG - '630': '58497702', // BCO LETSBANK S.A. - '633': '68900810', // BCO RENDIMENTO S.A. - '634': '17351180', // BCO TRIANGULO S.A. - '637': '60889128', // BCO SOFISA S.A. - '643': '62144175', // BCO PINE S.A. - '653': '61024352', // BANCO VOITER - '654': '92874270', // BCO DIGIMAIS S.A. - '655': '59588111', // BCO VOTORANTIM S.A. - '707': '62232889', // BCO DAYCOVAL S.A - '712': '78632767', // BCO OURINVEST S.A. - '720': '80271455', // BCO RNX S.A. - '739': '00558456', // BCO CETELEM S.A. - '741': '00517645', // BCO RIBEIRAO PRETO S.A. - '743': '00795423', // BANCO SEMEAR - '745': '33479023', // BCO CITIBANK S.A. - '746': '30723886', // BCO MODAL S.A. - '747': '01023570', // BCO RABOBANK INTL BRASIL S.A. - '748': '01181521', // BCO COOPERATIVO SICREDI S.A. - '751': '29030467', // SCOTIABANK BRASIL - '752': '01522368', // BCO BNP PARIBAS BRASIL S A - '753': '74828799', // NOVO BCO CONTINENTAL S.A. - BM - '754': '76543115', // BANCO SISTEMA - '755': '62073200', // BOFA MERRILL LYNCH BM S.A. - '756': '02038232', // BANCO SICOOB S.A. - '757': '02318507', // BCO KEB HANA DO BRASIL S.A. -}; diff --git a/src/modules/financial/adapters/cnab/multipag-segments.ts b/src/modules/financial/adapters/cnab/multipag-segments.ts index e365b930..3f034b55 100644 --- a/src/modules/financial/adapters/cnab/multipag-segments.ts +++ b/src/modules/financial/adapters/cnab/multipag-segments.ts @@ -65,18 +65,59 @@ const MOVEMENT_INSTRUCTION_BLOCKED = '09'; const CURRENCY_BRL = 'BRL'; // G040 -export type Payee = Readonly<{ +// QUEM É o favorecido — nome e inscrição. Separado do bloco bancário porque há registro que precisa +// só disto: o Segmento B, nas duas modalidades, identifica o favorecido por inscrição e nunca escreve +// conta (o convencional traz endereço e valores; o de Pix, a chave). Enquanto os dois andavam juntos +// num tipo só, um pagamento por chave era obrigado a carregar agência e conta para montar um registro +// que não as usa — que é a inversão que a #945 desfaz. +export type PayeeIdentity = Readonly<{ name: string; documentType: '1' | '2'; // 1 = CPF, 2 = CNPJ document: string; +}>; + +// ONDE cai o dinheiro, quando o registro precisa dizê-lo. Só o Segmento A tem estas posições +// (021-042), e só ele recebe este tipo. +// +// `accountAgencyDigit` NÃO existe aqui de propósito — ver a coluna 043 em `segmentA` (#754). +export type PayeeBankAccount = Readonly<{ bankCode: string; agency: string; agencyDigit: string; accountNumber: string; accountDigit: string; - // `accountAgencyDigit` NÃO existe aqui de propósito — ver a coluna 043 em `segmentA` (#754). }>; +export type Payee = PayeeIdentity & PayeeBankAccount; + +// ─── O bloco bancário do favorecido no Pix por chave — ZEROS, por laudo do banco ───────────── +// +// Fonte: laudo da equipe Multipag Pix/VAN do Bradesco, 05/09/2026, sobre o arquivo de teste enviado +// em 02/09. A pergunta foi literal — *"possibilidade de envio de banco, agência e conta do favorecido +// zerados quando o PIX for iniciado por chave"* — e a resposta também: *"é possível enviar os campos +// referentes ao banco, agência e conta do favorecido preenchidos com zeros"*. Conferido posição a +// posição no `.REM` que o banco aprovou. +// +// ⚠️ NÃO é "o cadastro está vazio, então vai zero". É o oposto: a rota `45` iniciada por chave NÃO LÊ +// o bloco bancário do favorecido, tenha ele um ou não. Um favorecido pode perfeitamente ter conta +// cadastrada — muitos têm, por serem também favorecidos de TED —, e ainda assim estas posições saem +// zeradas, porque quem endereça o pagamento no SPI é a chave. Escrever zeros por DECISÃO e escrever +// zeros por ausência de dado produzem o mesmo arquivo hoje e divergem no dia em que o cadastro +// estiver completo; só o primeiro continua correto. +// +// ⚠️ A COLUNA 043 NÃO ENTRA AQUI, e a omissão é a parte que se perde numa leitura rápida do laudo. A +// resposta nomeia CINCO campos — banco, agência, conta e os dois DVs —, e o DV agência/conta (G012) +// não é um deles: ele continua em BRANCO, pela regra do #754 extraída do validador oficial +// (`cnab-validator#2`). Zerar o bloco "inteiro" por simetria preencheria uma posição que o banco +// recusa. Por isso este tipo é `PayeeBankAccount`, que não tem o campo, e não um objeto solto. +export const PIX_ZEROED_PAYEE_ACCOUNT: PayeeBankAccount = { + bankCode: '000', + agency: '00000', + agencyDigit: '0', + accountNumber: '000000000000', + accountDigit: '0', +}; + export type PayeeAddress = Readonly<{ street?: string; number?: number; @@ -123,7 +164,10 @@ export type SegmentBInput = Readonly<{ bankCode: string; batchNumber: number; recordNumber: number; - payee: Payee; + // `PayeeIdentity`, e não `Payee`: este registro identifica o favorecido por inscrição (018-032) e + // não tem posição alguma de conta. Um `Payee` completo aqui pediria ao chamador dados que a linha + // não escreve — e é o que obrigava o Pix por chave a carregar bloco bancário (#945). + payee: PayeeIdentity; // G044 e G042, colunas 128-135 e 136-150 — o VENCIMENTO e o VALOR NOMINAIS do título, no grupo // "Dados Complementares – Pagamento" (layout p.25, campos 17.3B e 18.3B; descrições na p.103). // Ambos declarados **Obrigatório – Remessa / Retorno**, e ambos saíam zerados: é o defeito 5 da @@ -317,7 +361,9 @@ export type SegmentBPixInput = Readonly<{ bankCode: string; // banco do CEDENTE do arquivo, isto é, o pagador (001-003) batchNumber: number; recordNumber: number; - payee: Payee; + // `PayeeIdentity` pela mesma razão do Segmento B convencional, e aqui ela é a própria tese da + // #945: quem endereça o pagamento é a CHAVE, em 128-226. Este registro não tem onde escrever conta. + payee: PayeeIdentity; // G100, colunas 015-017 — os DOIS dígitos do domínio, já traduzidos por `pixInitiationFor`. Este // registro escreve o que recebeu e não tem opinião sobre qual iniciação é a certa, exatamente como // o Segmento A faz com `tedPurpose` e `complementPurpose`: quem deriva conhece a rota, quem monta @@ -326,10 +372,12 @@ export type SegmentBPixInput = Readonly<{ initiation: string; // G101, colunas 128-226 — a chave, como o cadastro a guarda. 99 posições Alfa alinhadas à esquerda. pixKey: string; - // P015, colunas 233-240 — ISPB do PSP do recebedor, derivado do código de compensação por - // `payeeIspbFor` (#923). Obrigatório na modalidade, e sem default pelo mesmo motivo do `dueDate` - // acima: um `?` com `?? ''` compilaria em todo chamador e emitiria oito brancos. - payeeIspb: string; + // O `payeeIspb` SAIU da assinatura (#923), e a remoção é o ponto — não uma simplificação. Enquanto + // o campo existiu, alguém tinha de produzir o valor, e a única fonte disponível era uma tabela + // local banco→ISPB que responde à pergunta errada: ela diz "qual o ISPB do banco X?" quando o + // arquivo pergunta "qual o ISPB da instituição que detém ESTA chave?". As duas respostas coincidem + // só enquanto a chave não sofrer portabilidade, e quando divergem nada sinaliza. Ver + // `PIX_ISPB_ZEROS`, abaixo. }>; // Dois dígitos, e a guarda é de COERÊNCIA INTERNA — o valor vem de `pixInitiationFor`, não do @@ -341,6 +389,29 @@ const isPixInitiation = (raw: string): boolean => // 99 posições é o TAMANHO DO CAMPO, e aqui ele é um limite real, não um alinhamento. const PIX_KEY_WIDTH = 99; +// ─── O ISPB na modalidade Pix — ZEROS, por laudo do banco (#923) ───────────────────────────── +// +// Vale para os DOIS lugares em que o layout pede o ISPB do PSP do recebedor: o `P015` do Segmento B +// (233-240) e o complemento do `G031` na Informação 2 do Segmento A (192-199, dentro do bloco +// 178-217). Um só literal porque é um só fato — dois valores separados divergiriam, e o arquivo +// afirmaria duas instituições diferentes para o mesmo pagamento. +// +// **Fonte, e ela é a mais alta que este repositório reconhece:** laudo da equipe Multipag Pix/VAN do +// Bradesco, 05/09/2026, respondendo à pergunta literal *"ISPB preenchido com 00000000, conforme +// orientação recebida"* com *"os questionamentos encaminhados abaixo também estão corretos e em +// conformidade com o layout"*. Antes disso o emissor derivava o valor de uma tabela embarcada +// (#934), alinhado ao golden — que preenche o campo porque PODE, não porque precisa. O manual v08 +// concorda: o `P015` é obrigatório só para "TED para instituição financeira que não possui código +// COMPE", que não é esta rota. +// +// ⚠️ **NÃO reintroduzir uma tabela banco→ISPB se o campo voltar a ser exigido de verdade.** A origem +// correta é o DICT, por resolução da chave — é ele que sabe em qual instituição a chave está HOJE. +// Chave sofre portabilidade, reivindicação de posse e troca de conta vinculada; uma tabela derivada +// do banco cadastrado acerta enquanto os dois coincidirem e erra em silêncio quando deixam de +// coincidir. O acesso ao DICT é dos participantes do Pix, então isso dependeria de API do +// Bradesco/VAN — e é por isso que a volta do campo é decisão de produto, não uma linha a mais aqui. +const PIX_ISPB_ZEROS = '00000000'; + export const segmentBPix = (input: SegmentBPixInput): Result => { const { payee: p } = input; @@ -383,7 +454,7 @@ export const segmentBPix = (input: SegmentBPixInput): Result; @@ -436,7 +509,7 @@ export const pixPaymentInfo = (input: PixPaymentInfoInput): Result => { - if (!BANK_CODE_SHAPE.test(bankCode)) return err('payee-ispb-unknown'); - - const ispb = ISPB_BY_BANK_CODE[bankCode]; - // `undefined` é instituição fora da tabela — banco novo, ou fonte desatualizada. NUNCA cair para - // zeros, brancos ou para o próprio código zero-padded: os três produzem arquivo bem-formado que o - // banco recusa, e o `remittance-inspector.ts` não pega, porque não é defeito de forma. É a mesma - // classe do `?? ''` que virou endereço em branco em 100% das remessas (#858). - return ispb === undefined ? err('payee-ispb-unknown') : ok(ispb); -}; diff --git a/src/modules/financial/adapters/cnab/pix-initiation.ts b/src/modules/financial/adapters/cnab/pix-initiation.ts index 36d5063d..56a4c19f 100644 --- a/src/modules/financial/adapters/cnab/pix-initiation.ts +++ b/src/modules/financial/adapters/cnab/pix-initiation.ts @@ -8,8 +8,8 @@ import { ok, err, type Result } from '#src/shared/index.ts'; * cinco valores cada, de donos diferentes — `PixKeyType` é de `partners` * (`domain/shared/payment-target.ts:10`) e o `G100` é do banco (`03-dominios-campos.md:254`). * - * ⚠️ VIVE NO ADAPTER, e não no domínio, pela mesma razão que `payee-ispb.ts`: a tradução é da - * fronteira com o banco, e é aqui que ela morre quando o layout mudar. O `financial` mantém o + * ⚠️ VIVE NO ADAPTER, e não no domínio, pela mesma razão que as demais constantes do layout: a + * tradução é da fronteira com o banco, e é aqui que ela morre quando o layout mudar. O `financial` mantém o * `keyType` OPACO de propósito (`domain/payout/types.ts:57-62`) — o payout decide aptidão, e para * isso basta haver chave. Interpretar o tipo é trabalho de quem emite o registro, que é este arquivo. * @@ -49,9 +49,10 @@ export type PixInitiationError = 'remittance-pix-key-type-unsupported'; // ⚠️ `Map`, e NÃO um objeto literal, e a razão é de segurança, não de estilo. Um // `Record` herda de `Object.prototype`, e `keyType` chega como string arbitrária: // `record['toString']` devolveria uma FUNÇÃO, que não é `undefined` e passaria pela guarda abaixo -// como se fosse código `G100` válido. O `payee-ispb.ts` escapa disso por ter uma guarda de forma -// (`^\d{3}$`) que barra `'toString'` antes do acesso — e há teste lá fixando exatamente esse caso. -// Aqui não existe forma numérica a validar, então a estrutura é que precisa não ter protótipo. +// como se fosse código `G100` válido. Um mapa banco→ISPB que vivia aqui ao lado escapava disso por +// ter uma guarda de forma (`^\d{3}$`) que barrava `'toString'` antes do acesso — ele saiu na #923, +// mas a lição fica. Aqui não existe forma numérica a validar, então a estrutura é que precisa não +// ter protótipo. const INITIATION_BY_KEY_TYPE: ReadonlyMap = new Map([ ['phone', '01'], ['email', '02'], @@ -67,7 +68,7 @@ export const pixInitiationFor = (keyType: string): Result; +export type RemittancePayeeData = RemittancePayeeIdentity & + Readonly<{ + bankCode: string; + agency: string; + agencyDigit: string; + accountNumber: string; + accountDigit: string; + }>; + export type RemittanceCedenteData = Readonly<{ bankCode: string; documentType: '1' | '2'; @@ -68,18 +74,23 @@ export type RemittanceBilletPayment = Readonly<{ // Pagamento por chave Pix, forma `45` (#838). // -// ⚠️ CARREGA `payee` COMPLETO, e a leitura errada a evitar é concluir daí que "o Pix passou a ser uma -// transferência". Ele não é: quem endereça o pagamento no SPI é a CHAVE, e é ela que vai no Segmento -// B. O bloco bancário está aqui porque o Segmento A — que continua sendo o registro de crédito — -// identifica a conta, e o golden do banco o traz preenchido, com o layout marcando os campos como -// obrigatórios (p. 39). Duas coisas verdadeiras ao mesmo tempo, e a #708 só havia declarado uma. +// ⚠️ **NÃO carrega bloco bancário, e a mudança é de premissa, não de conveniência (#945).** A versão +// anterior deste tipo exigia `RemittancePayeeData` completo, com o argumento de que o Segmento A +// "identifica a conta" e o golden o traz preenchido. O golden prova como AQUELE arquivo foi montado, +// não o que o ERP deve coletar — e o laudo do Bradesco de 05/09/2026 fechou a questão: banco, agência +// e conta do favorecido podem sair ZERADOS quando o Pix é iniciado por chave. Quem endereça o +// pagamento no SPI é a chave; as posições 021-042 do Segmento A são preenchimento técnico, resolvido +// na serialização por `PIX_ZEROED_PAYEE_ACCOUNT`, e não requisito funcional do pagamento. +// +// O efeito de tipar assim é o que importa: um chamador não CONSEGUE mais alimentar o Segmento A com a +// conta cadastrada do favorecido nesta rota, e o pré-voo não tem mais como exigi-la. // // O `keyType` viaja CRU, no vocabulário de `partners`. A tradução para o domínio `G100` é do adapter -// — a application não conhece layout —, e é o mesmo arranjo do ISPB, que nem sequer aparece aqui: -// ele é derivado do código de compensação lá dentro. +// — a application não conhece layout. O ISPB não aparece aqui pelo mesmo motivo, e desde a #923 nem +// sequer é derivado: é constante do layout (`PIX_ISPB_ZEROS`). export type RemittancePixPayment = Readonly<{ route: 'pix'; - payee: RemittancePayeeData; + payee: RemittancePayeeIdentity; pixKey: string; pixKeyType: string; valueCents: number; @@ -152,13 +163,12 @@ export type CnabTranslateError = // CADASTRO —, e o nome próprio existe porque a alternativa é pior do que um erro genérico: sem ele // a chave seria truncada e o pagamento sairia bem-formado para OUTRO recebedor. | 'cnab-pix-key-unrepresentable' - // Os outros dois da rota Pix, e cada um manda o operador a um lugar DIFERENTE — que é a única - // razão pela qual três erros de uma rota só não são exagero: - // · `ispb-unknown` → o banco do favorecido não está na tabela do Bacen. A saída é atualizar - // a fonte embarcada, ou corrigir o código de compensação no cadastro. - // · `key-type-unsupported` → o tipo da chave não existe no domínio `G100` do layout. Não há o que - // atualizar: ou o cadastro gravou fora do contrato, ou o layout mudou. - | 'cnab-payee-ispb-unknown' + // O outro da rota Pix: `key-type-unsupported` diz que o tipo da chave não existe no domínio `G100` + // do layout. Não há o que atualizar — ou o cadastro gravou fora do contrato, ou o layout mudou. + // + // Eram três. O `cnab-payee-ispb-unknown` saiu na #923, quando o ISPB deixou de ser derivado de uma + // tabela embarcada e virou constante do layout: sem tabela não há banco desconhecido, e um erro que + // não pode mais ocorrer é um caminho que os chamadores continuariam tratando à toa. | 'cnab-pix-key-type-unsupported'; // ─── A partição em arquivos (CA4 da #838) ────────────────────────────────────────────────────── diff --git a/src/modules/financial/application/use-cases/generate-remittance.ts b/src/modules/financial/application/use-cases/generate-remittance.ts index b9e27ba0..3226de62 100644 --- a/src/modules/financial/application/use-cases/generate-remittance.ts +++ b/src/modules/financial/application/use-cases/generate-remittance.ts @@ -259,15 +259,16 @@ export const generateRemittance = // comentário do `allocateNsa` acima), mas a recusa parcial não é — e é por isso que a // gravação é UMA para todas as remessas, em `saveAll`, e não uma por arquivo. // - // ⚠️ OS TRÊS DA ROTA PIX (#838) convergem para o MESMO desfecho, e a convergência é escolha - // registrada, não efeito de `default`: nos três o operador vai ao cadastro do favorecido, e - // a tela é a do pré-voo. O que os distingue — chave que não cabe no campo, banco fora da - // tabela do Bacen, tipo de chave fora do domínio do layout — importa para quem lê o log do - // emissor, e é por isso que eles sobem NOMEADOS até aqui em vez de serem achatados na - // origem. Chegar em qualquer um deles é rede de segurança: o reader já recusou antes do NSA. + // ⚠️ OS DOIS DA ROTA PIX (#838) convergem para o MESMO desfecho, e a convergência é escolha + // registrada, não efeito de `default`: nos dois o operador vai ao cadastro do favorecido, e + // a tela é a do pré-voo. O que os distingue — chave que não cabe no campo, tipo de chave + // fora do domínio do layout — importa para quem lê o log do emissor, e é por isso que eles + // sobem NOMEADOS até aqui em vez de serem achatados na origem. Chegar em qualquer um deles + // é rede de segurança: o reader já recusou antes do NSA. + // + // Eram três; o `cnab-payee-ispb-unknown` saiu na #923 junto com a tabela de-para de ISPB. case 'cnab-billet-party-unidentified': case 'cnab-pix-key-unrepresentable': - case 'cnab-payee-ispb-unknown': case 'cnab-pix-key-type-unsupported': return err('remittance-payments-unavailable'); case 'cnab-translation-failed': diff --git a/src/modules/financial/domain/payout/payout-readiness.ts b/src/modules/financial/domain/payout/payout-readiness.ts index d89946dc..4f3f935f 100644 --- a/src/modules/financial/domain/payout/payout-readiness.ts +++ b/src/modules/financial/domain/payout/payout-readiness.ts @@ -129,41 +129,42 @@ const readBilletPayee = ( const checkRouteData = (candidate: PayoutCandidate, route: VanRoute): RouteDataCheck => { switch (route) { - // ⚠️ O PIX PASSOU A EXIGIR CHAVE **E** BLOCO BANCÁRIO (#838), e esta é a linha que mais - // provavelmente alguém vai querer reverter lendo a decisão da P.O. de 13/08 (#708) — que dizia, - // com todas as letras, que "PIX paga por chave, não olha agência ou conta". A decisão não estava - // errada sobre o NEGÓCIO; ela estava incompleta sobre o ARQUIVO, e quem desfaz a diferença - // reabre a divergência que a #837 fechou. + // ⚠️ O PIX EXIGE A CHAVE, E SÓ ELA — a exigência de bloco bancário que vigorou entre a #838 e a + // #945 foi REVERTIDA, e esta nota existe para que ela não volte por releitura do golden. // - // A evidência é do banco, não de leitura: no golden `GOLDEN_TEST_MULTIPAG_PIX_240` (forma `45`), - // o Segmento A traz banco, agência, DV, conta e DV do favorecido TODOS PREENCHIDOS — e o layout - // (p. 39) marca os quatro com asterisco de obrigatório. A chave endereça o pagamento no SPI; o - // Segmento A continua sendo o registro de crédito, e ele identifica a conta. + // O argumento que a sustentava era: o Segmento A do golden `GOLDEN_TEST_MULTIPAG_PIX_240` traz + // banco, agência, DV, conta e DV do favorecido preenchidos, e o layout marca os campos com + // asterisco. Os dois fatos são verdadeiros e nenhum dos dois sustenta a conclusão: // - // A dependência já existia sem estar declarada, e é o que torna esta mudança menos brusca do que - // parece: `payeeIspbFor` (#923) deriva o ISPB do CÓDIGO DE COMPENSAÇÃO do favorecido, que é parte - // deste mesmo bloco. O emissor de Pix já não funcionava sem cadastro bancário; o que faltava era - // o pré-voo dizer isso ANTES da geração, em vez de no último clique. + // · o golden prova como AQUELE arquivo foi montado, não o que o ERP deve coletar; + // · 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` (DV da agência) e escrever na descrição + // "(Campo Não Obrigatório – Informação Opcional)". E o `P002`, código do banco do favorecido, + // não tem asterisco, embora agência e conta sem banco não signifiquem nada. // - // ACUMULA as duas pendências, como o boleto faz logo abaixo e pela mesma razão: quem tem Pix sem - // chave E sem conta precisa ver as duas de uma vez, não uma volta ao cadastro por vez. A chave - // vem primeiro por ser o dado que define a ROTA — sem ela o operador escolheu Pix por engano. + // **A arbitragem veio do banco, por escrito** (laudo da equipe Multipag Pix/VAN, 05/09/2026): + // banco, agência e conta do favorecido podem sair ZERADOS quando o Pix é iniciado por chave. O + // emissor passou a fazê-lo (`PIX_ZEROED_PAYEE_ACCOUNT`), e é isso que autoriza esta régua a + // relaxar — nesta ordem, nunca na inversa: enquanto o Segmento A lia a conta do cadastro, um + // pré-voo permissivo aprovaria o que o montador recusaria com `numeric-field-invalid`, DEPOIS do + // `allocateNsa`. É exatamente a divergência que a #837 fechou, e a razão de a #945 fixar os + // passos em sequência. + // + // A dependência transversal que existia também caiu: `payeeIspbFor` derivava o ISPB do código de + // compensação do cadastro, e desde a #923 o ISPB é constante do layout. Não sobrou nada na rota + // Pix que leia o bloco bancário do favorecido. // // Chave em branco é chave ausente: o cadastro guarda `''` com mais frequência que `null` (ver o // CHECK do bloco bancário, em `types.ts`). E conta completa NÃO substitui a chave — quem escolheu // PIX no lançamento paga por PIX, e trocar a rota mudaria o custo e o prazo que ele aceitou. - case 'pix': { - const keyGap = isBlank(candidate.payee?.pixKey?.key ?? null) - ? missingField(route, 'pix-key') - : null; - const parts = decomposePayeeAccount(candidate.payee); - if (keyGap === null && parts.ok) return ready(route); - - return incomplete( - route, - immutable([...(keyGap === null ? [] : keyGap.gaps), ...(parts.ok ? [] : parts.error)]), - ); - } + // + // ⚠️ Efeito colateral DESEJADO, e que aparece na tela: some o `check-digit-mismatch` na rota Pix. + // Favorecido com DV divergente era bloqueado aqui; passa a pagar, e está certo — o DV não vai no + // arquivo. A régua de DV continua valendo onde o dígito é escrito, que é a transferência. + case 'pix': + return isBlank(candidate.payee?.pixKey?.key ?? null) + ? incomplete(route, missingField(route, 'pix-key').gaps) + : ready(route); // Única rota que depende da conta estruturada — e, portanto, a única em que o desencaixe do // cadastro vira impedimento de pagamento. diff --git a/tests/modules/financial/adapters/cnab/multipag-segment-b-pix.test.ts b/tests/modules/financial/adapters/cnab/multipag-segment-b-pix.test.ts index 497721a1..93705a1f 100644 --- a/tests/modules/financial/adapters/cnab/multipag-segment-b-pix.test.ts +++ b/tests/modules/financial/adapters/cnab/multipag-segment-b-pix.test.ts @@ -41,7 +41,6 @@ const input = (over: Partial = {}): SegmentBPixInput => ({ payee: PAYEE, initiation: '04', pixKey: '00000000-0000-4000-8000-000000000000', - payeeIspb: '00000000', ...over, }); diff --git a/tests/modules/financial/adapters/cnab/payee-ispb.test.ts b/tests/modules/financial/adapters/cnab/payee-ispb.test.ts deleted file mode 100644 index 8336a02c..00000000 --- a/tests/modules/financial/adapters/cnab/payee-ispb.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it } from 'node:test'; -import { strict as assert } from 'node:assert'; - -import { isOk } from '#src/shared/index.ts'; -import { payeeIspbFor } from '#src/modules/financial/adapters/cnab/payee-ispb.ts'; -import { ISPB_BY_BANK_CODE } from '#src/modules/financial/adapters/cnab/ispb-by-bank-code.generated.ts'; - -/* - * ⚠️ NENHUM PAR (banco, ISPB) LITERAL NESTE ARQUIVO, e a omissão é deliberada. - * - * O ISPB de instituição é dado público do Bacen — o que não pode viver num repositório público é - * dado de CADASTRO, e um assert do tipo `payeeIspbFor('237') === '60746948'` convidaria o próximo - * teste a escrever o par do favorecido junto. Pior: ele testaria o CONTEÚDO da fonte, que muda quando - * o Bacen republica, em vez da PROPRIEDADE da função, que não muda. Um teste que quebra ao atualizar - * a tabela treina quem o vê a atualizar o assert sem pensar. - * - * Os casos abaixo comparam contra o próprio mapa gerado, ou afirmam forma — nunca valor decorado. - */ - -describe('payeeIspbFor — o ISPB do favorecido vem do código de compensação (#923)', () => { - it('devolve o ISPB de um banco presente na tabela', () => { - // Pega a primeira entrada do mapa em vez de fixar um banco: o teste continua valendo quando a - // fonte for republicada, e continua falhando se a função parar de consultar a tabela. - const [bankCode, expected] = Object.entries(ISPB_BY_BANK_CODE)[0] ?? []; - assert.ok(bankCode !== undefined && expected !== undefined, 'mapa gerado está vazio'); - - const r = payeeIspbFor(bankCode); - assert.ok(isOk(r), 'banco presente na tabela deve resolver'); - assert.equal(r.value, expected); - }); - - it('todo ISPB do mapa tem exatamente 8 dígitos — é o que o P015 exige', () => { - // A propriedade que o layout cobra, medida sobre a tabela inteira. Uma entrada de 7 posições - // passaria pela função e só apareceria como recusa do banco, depois de transmitida. - const malformed = Object.entries(ISPB_BY_BANK_CODE) - .filter(([, ispb]) => !/^\d{8}$/.test(ispb)) - .map(([code]) => code); - assert.deepEqual(malformed, []); - }); - - it('toda chave do mapa tem exatamente 3 dígitos', () => { - const malformed = Object.keys(ISPB_BY_BANK_CODE).filter((code) => !/^\d{3}$/.test(code)); - assert.deepEqual(malformed, []); - }); - - it('a tabela cobre o universo que o Bacen publica, não um recorte', () => { - // ⚠️ Guarda contra o defeito que quase entrou: filtrar por `Participa_da_Compe = Sim` deixaria - // apenas 95 das 348 instituições — e Nubank e C6 estão entre as 253 que ficariam de fora, sendo - // favorecidos correntes. O número redondo denuncia o recorte; a ordem de grandeza, não. - assert.ok( - Object.keys(ISPB_BY_BANK_CODE).length > 300, - `mapa com ${String(Object.keys(ISPB_BY_BANK_CODE).length)} entradas — recorte da fonte?`, - ); - }); - - // ── O caminho de erro, que é o que impede pagamento para o destino errado ────────────────────── - - it('recusa banco que não está na tabela, em vez de inventar', () => { - // `999` não é participante. O que NÃO pode acontecer é devolver zeros, brancos ou o próprio - // código zero-padded: os três produzem arquivo bem-formado que o banco recusa, e o inspetor não - // pega porque não é defeito de forma. - const r = payeeIspbFor('999'); - assert.equal(isOk(r), false); - assert.equal(isOk(r) ? null : r.error, 'payee-ispb-unknown'); - }); - - it('recusa entrada malformada — vazia, curta, longa ou não numérica', () => { - for (const bad of ['', '1', '00', '0001', '23a', ' ', '237 ']) { - const r = payeeIspbFor(bad); - assert.equal(isOk(r), false, `deveria recusar ${JSON.stringify(bad)}`); - } - }); - - it('não resolve por prefixo nem por coerção — a chave é exata', () => { - // Um `Record` aceita qualquer string; sem a guarda de forma, `'toString'` e - // `'constructor'` alcançariam o protótipo e devolveriam uma função como se fosse ISPB. - for (const attack of ['toString', 'constructor', '__proto__', 'valueOf']) { - const r = payeeIspbFor(attack); - assert.equal(isOk(r), false, `deveria recusar ${attack}`); - } - }); -}); diff --git a/tests/modules/financial/adapters/cnab/remittance-file.test.ts b/tests/modules/financial/adapters/cnab/remittance-file.test.ts index 578c47b6..8b64da3d 100644 --- a/tests/modules/financial/adapters/cnab/remittance-file.test.ts +++ b/tests/modules/financial/adapters/cnab/remittance-file.test.ts @@ -793,14 +793,20 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { // ⚠️ O favorecido do Pix carrega bloco bancário COMPLETO, e é o que o golden mostra no Segmento A. // Ver `PixPayment`, no montador: a chave endereça no SPI, o Segmento A identifica a conta. - const pix = (n: number, valueCents: number, over: Partial = {}): RemittancePayment => ({ - route: 'pix', - payee: { ...payee(n), ...over }, - pixKey: PIX_KEY, - pixKeyType: 'random-key', - paymentDate: PAYMENT_DATE, - valueCents, - }); + // O favorecido de Pix é IDENTIDADE, e só: desde a #945 o tipo não tem onde guardar conta. O + // `over` some junto — não há mais campo bancário que um caso de teste pudesse variar, que é + // exatamente a garantia que a mudança de tipo dá. + const pix = (n: number, valueCents: number): RemittancePayment => { + const { name, documentType, document } = payee(n); + return { + route: 'pix', + payee: { name, documentType, document }, + pixKey: PIX_KEY, + pixKeyType: 'random-key', + paymentDate: PAYMENT_DATE, + valueCents, + }; + }; const buildPix = (payments: readonly RemittancePayment[]) => { const r = buildRemittanceFile({ ...base, payments }); @@ -839,16 +845,30 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { assert.equal(at(batchHeader, 14, 16), '045'); }); - it('o Segmento A transita pelo SPI e identifica a CONTA do favorecido', () => { - // A metade contra-intuitiva da rota, e a que a #708 não previa: o bloco bancário sai preenchido. - // Um Segmento A zerado aqui seria o defeito silencioso — arquivo bem-formado, crédito sem destino. + it('o Segmento A transita pelo SPI e ZERA o bloco bancário do favorecido', () => { + // A régua que o laudo do Bradesco de 05/09/2026 fixou (#945): no Pix iniciado por chave, banco, + // agência, conta e os dois DVs saem zerados. Quem endereça o pagamento no SPI é a chave, em + // 128-226 do Segmento B — estas posições são preenchimento técnico, não destino do dinheiro. + // + // Este teste é a INVERSÃO do que existia aqui até a #945, que afirmava o oposto apoiado no + // golden. O golden prova como aquele arquivo foi montado; o laudo diz o que o banco aceita. const a = linesOf(buildPix([pix(1, 100_00)]).content)[2] ?? ''; - const p = payee(1); assert.equal(at(a, 18, 20), '009', 'câmara do SPI'); - assert.equal(at(a, 21, 23), p.bankCode); - assert.equal(at(a, 24, 28), p.agency.padStart(5, '0')); - assert.equal(at(a, 30, 41), p.accountNumber.padStart(12, '0')); + assert.equal(at(a, 21, 23), '000', 'banco do favorecido'); + assert.equal(at(a, 24, 28), '00000', 'agência'); + assert.equal(at(a, 29, 29), '0', 'DV da agência'); + assert.equal(at(a, 30, 41), '000000000000', 'conta'); + assert.equal(at(a, 42, 42), '0', 'DV da conta'); + }); + + it('a coluna 043 continua em BRANCO, e não zerada junto com o resto do bloco', () => { + // A armadilha do laudo: ele nomeia CINCO campos, e o DV agência/conta (G012) não é um deles. + // Zerar o bloco "inteiro" por simetria preencheria a posição que o validador oficial recusa + // (#754, `cnab-validator#2`). Um teste próprio porque é o erro que uma leitura rápida produz. + const a = linesOf(buildPix([pix(1, 100_00)]).content)[2] ?? ''; + + assert.equal(at(a, 43, 43), ' '); }); it('o Segmento A não leva finalidade de TED nem finalidade complementar', () => { @@ -869,7 +889,7 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { const p = payee(1); assert.equal(at(a, 178, 191), p.document.padStart(14, '0'), 'inscrição do favorecido'); - assert.match(at(a, 192, 199), /^\d{8}$/, 'ISPB derivado do código de compensação'); + assert.equal(at(a, 192, 199), '00000000', 'ISPB — zeros, por laudo do banco (#923)'); assert.equal(at(a, 200, 201), '01', 'conta corrente — premissa da P.O., ver #817'); assert.equal(at(a, 202, 217), ' '.repeat(16)); }); @@ -881,13 +901,13 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { assert.equal(at(b, 33, 67), ' '.repeat(35), 'TXID'); assert.equal(at(b, 128, 226).trimEnd(), PIX_KEY); assert.equal(at(b, 227, 232), '000000', 'UG SIAPE zerada, como no golden'); - assert.match(at(b, 233, 240), /^\d{8}$/, 'ISPB do PSP'); + assert.equal(at(b, 233, 240), '00000000', 'ISPB do PSP — zeros, por laudo do banco (#923)'); }); - it('o ISPB do Segmento A e o do Segmento B são o mesmo — os dois vêm da mesma origem', () => { + it('o ISPB do Segmento A e o do Segmento B são o mesmo — um só literal para um só fato', () => { // Dois campos do mesmo pagamento afirmando instituições diferentes seria arquivo bem-formado e - // incoerente. É por isso que o ISPB é DERIVADO uma vez, do código de compensação, e passado aos - // dois registros — em vez de recebido pronto do chamador. + // incoerente. Desde a #923 os dois leem a MESMA constante (`PIX_ISPB_ZEROS`), e nenhum dos dois + // recebe o valor do chamador — que é o que torna a divergência inalcançável, e não só improvável. const lines = linesOf(buildPix([pix(1, 100_00)]).content); const [a, b] = [lines[2] ?? '', lines[3] ?? '']; @@ -910,11 +930,19 @@ describe('Remessa Multipag — o arquivo de Pix por chave (#838)', () => { assert.equal(r.error, 'remittance-mixed-file-modalities'); }); - it('recusa o favorecido cujo banco não está na tabela de ISPB, sem inventar', () => { - // `999` não é participante. O que não pode acontecer é sair com oito zeros: o inspetor aprova — - // não é defeito de forma — e o banco recusa depois de transmitido. - const r = buildRemittanceFile({ ...base, payments: [pix(1, 100_00, { bankCode: '999' })] }); - assert.ok(isErr(r)); - assert.equal(r.error, 'payee-ispb-unknown'); + it('emite sem consultar banco algum do favorecido — a recusa por ISPB desconhecido não existe mais', () => { + // O contrário do que este bloco testava até a #923, e a inversão é o ponto. Havia aqui um caso + // exigindo `payee-ispb-unknown` para banco fora da tabela de-para; com o ISPB virando constante + // do layout, não há tabela a consultar e portanto não há banco a desconhecer. + // + // Custava caro: a recusa vinha DEPOIS do `allocateNsa`, então cada favorecido de instituição + // fora da tabela queimava um número da série antes de o operador saber que não daria (#948). + // + // O que este teste fixa é que o arquivo sai para um favorecido sem nenhum dado bancário — que é o + // cenário que a modalidade existe para atender, e que o tipo agora torna o único possível. + const r = buildRemittanceFile({ ...base, payments: [pix(1, 100_00)] }); + + assert.ok(isOk(r), `esperava arquivo, veio ${isErr(r) ? r.error : '?'}`); + assert.equal(linesOf(r.value.content).length, 6); }); }); diff --git a/tests/modules/financial/application/use-cases/preview-remittance.test.ts b/tests/modules/financial/application/use-cases/preview-remittance.test.ts index c1bc73e6..b16193e9 100644 --- a/tests/modules/financial/application/use-cases/preview-remittance.test.ts +++ b/tests/modules/financial/application/use-cases/preview-remittance.test.ts @@ -159,7 +159,7 @@ describe('previewRemittance — responde por título, sem gerar arquivo', () => row({ payableId: 'ted-sem-banco', payee: NO_DESTINATION_NULLS }), row({ payableId: 'pix-ok', paymentMethod: 'PIX', payee: PIX_COMPLETE }), row({ payableId: 'pix-sem-chave', paymentMethod: 'PIX', payee: BANK_ACCOUNT_ONLY }), - // O caso que a #838 criou: chave presente, cadastro bancário ausente. Antes bastava a chave. + // Chave presente, cadastro bancário ausente. A #838 fez disto um bloqueio; a #945 desfez. row({ payableId: 'pix-sem-conta', paymentMethod: 'PIX', payee: PIX_KEY_ONLY }), // 44 dígitos — o código de barras que o Segmento J grava (G063). row({ @@ -195,10 +195,11 @@ describe('previewRemittance — responde por título, sem gerar arquivo', () => // para a rota. Antes, ela aparecia como `ready` e a recusa só chegava no clique em Gerar. assert.equal(line(r, 'guia-ok').status, 'no-issuer'); - // #838 — a metade nova da régua do Pix. Chave presente e cadastro bancário ausente é pendência - // de CADASTRO, não ausência de emissor: o operador tem o que fazer, e a tela precisa dizer o quê. - assert.equal(line(r, 'pix-sem-conta').status, 'blocked'); - assert.ok(line(r, 'pix-sem-conta').missing.length > 0, 'a tela precisa apontar o campo'); + // #945 — a régua do Pix voltou a ser a chave, e só ela. Favorecido com chave e sem nenhum dado + // bancário SAI: banco, agência e conta do Segmento A vão zerados, por laudo do banco de + // 05/09/2026. Era o cenário que a modalidade existe para atender, e que a #838 represava. + assert.equal(line(r, 'pix-sem-conta').status, 'ready'); + assert.deepEqual(line(r, 'pix-sem-conta').missing, [], 'não há cadastro a cobrar'); // #837 — CA4. Os dois motivos coexistem e não se confundem: aqui falta a CHAVE, e o operador tem // o que fazer. Achatá-lo em `no-issuer` esconderia a pendência de cadastro atrás de um diff --git a/tests/modules/financial/domain/payout/payout-readiness.test.ts b/tests/modules/financial/domain/payout/payout-readiness.test.ts index c7e90c18..986a238c 100644 --- a/tests/modules/financial/domain/payout/payout-readiness.test.ts +++ b/tests/modules/financial/domain/payout/payout-readiness.test.ts @@ -123,7 +123,7 @@ describe('checkPayoutReadiness — a forma de pagamento decide o que o arquivo e const pixPayee = (): PayeePaymentTarget => target({ ...fullAccount(), pixKey: { keyType: 'email', key: 'a@b.com' } }); -describe('checkPayoutReadiness — PIX exige a chave E o bloco bancário (#838)', () => { +describe('checkPayoutReadiness — PIX exige a chave, e só ela (#945)', () => { it('aceita PIX com chave e conta completa', () => { const r = checkPayoutReadiness(candidate({ paymentMethod: 'PIX', payee: pixPayee() })); assert.equal(r.status, whenDataIsGood('pix')); @@ -169,34 +169,54 @@ describe('checkPayoutReadiness — PIX exige a chave E o bloco bancário (#838)' }); // Conta completa não substitui chave: quem escolheu PIX no lançamento paga por PIX, e trocar a - // rota mudaria o custo e o prazo que o operador aceitou. Continua valendo — o que mudou é que - // agora a conta é exigida ALÉM da chave, não NO LUGAR dela. + // rota mudaria o custo e o prazo que o operador aceitou. Sobreviveu às duas viradas de régua — + // a conta nunca bastou, nem quando era exigida além da chave (#838), nem agora que não é exigida. it('não aceita conta bancária no lugar da chave PIX', () => { const r = checkPayoutReadiness(candidate({ paymentMethod: 'PIX', payee: fullAccount() })); assert.equal(r.status, 'incomplete'); assert.deepEqual(fieldsOf(r), ['pix-key']); }); - // O caso que a mudança de régua criou, e o que ele fixa é a ACUMULAÇÃO: quem tem PIX sem chave e - // sem conta precisa ver as duas pendências de uma vez, não uma volta ao cadastro por vez. - it('acumula as pendências de chave e de conta, sem parar na primeira', () => { + // A chave é a ÚNICA pendência possível nesta rota. Sem cadastro nenhum, o operador recebe um + // caminho só — e não uma lista de campos bancários que o pagamento não vai usar. + it('aponta só a chave quando o favorecido não tem cadastro algum', () => { const r = checkPayoutReadiness(candidate({ paymentMethod: 'PIX' })); assert.equal(r.status, 'incomplete'); - assert.ok(fieldsOf(r).includes('pix-key'), 'a chave tem de aparecer'); - assert.ok(fieldsOf(r).length > 1, `esperava conta junto, veio ${fieldsOf(r).join(', ')}`); + assert.deepEqual(fieldsOf(r), ['pix-key']); }); - // A chave sozinha deixou de bastar, e este é o teste que prova a mudança — o inverso exato do que - // a suíte afirmava antes da #838. - it('recusa PIX com chave mas SEM dado bancário', () => { + // ⚠️ O TESTE QUE FIXA A REVERSÃO DA #945, e o inverso exato do que a suíte afirmava entre a #838 e + // ela. Favorecido com chave e SEM nenhum dado bancário é o cenário que a modalidade existe para + // atender; exigir conta aqui represava justamente ele. + // + // A arbitragem foi do banco, por escrito (laudo de 05/09/2026): banco, agência e conta do + // favorecido podem sair zerados no Pix iniciado por chave. O emissor passou a zerá-los, e é isso + // que autoriza esta régua — nesta ordem. Ver `PIX_ZEROED_PAYEE_ACCOUNT`. + it('aceita PIX com chave e SEM dado bancário algum', () => { const r = checkPayoutReadiness( candidate({ paymentMethod: 'PIX', payee: target({ pixKey: { keyType: 'email', key: 'a@b.com' } }), }), ); - assert.equal(r.status, 'incomplete'); - assert.equal(fieldsOf(r).includes('pix-key'), false, 'a chave está lá; o que falta é a conta'); + assert.equal(r.status, whenDataIsGood('pix')); + }); + + // O efeito colateral desejado da reversão, e ele merece teste próprio por ser o que mais surpreende + // quem conhece a régua da transferência: o DV divergente deixa de bloquear no Pix. Está certo — o + // dígito não vai no arquivo, e as posições saem zeradas. A régua de DV continua onde ele é escrito. + it('não bloqueia por DV divergente — o dígito não vai no arquivo desta rota', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ + ...fullAccount(), + checkDigit: '9', + pixKey: { keyType: 'random-key', key: 'a-b-c' }, + }), + }), + ); + assert.equal(r.status, whenDataIsGood('pix')); }); }); From 99391c3d077f0d3423f0a74af0efe8b133ca9169 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sat, 5 Sep 2026 13:50:51 -0300 Subject: [PATCH 02/13] =?UTF-8?q?fix(financial):=20o=20pr=C3=A9-voo=20do?= =?UTF-8?q?=20Pix=20passa=20a=20checar=20as=20duas=20condi=C3=A7=C3=B5es?= =?UTF-8?q?=20da=20chave=20(#948)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`, 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) Claude-Session: https://claude.ai/code/session_01NJKkFN7sQDoBS8AvteK1kZ --- .../adapters/cnab/multipag-segments.ts | 7 +- .../financial/adapters/cnab/pix-initiation.ts | 46 ++++-- .../domain/payout/payout-readiness.ts | 43 +++++- .../financial/domain/payout/pix-key.ts | 90 +++++++++++ .../domain/payout/payout-readiness.test.ts | 140 ++++++++++++++++-- .../payout/pix-key-single-source.test.ts | 103 +++++++++++++ 6 files changed, 394 insertions(+), 35 deletions(-) create mode 100644 src/modules/financial/domain/payout/pix-key.ts create mode 100644 tests/modules/financial/domain/payout/pix-key-single-source.test.ts diff --git a/src/modules/financial/adapters/cnab/multipag-segments.ts b/src/modules/financial/adapters/cnab/multipag-segments.ts index 3f034b55..165b18af 100644 --- a/src/modules/financial/adapters/cnab/multipag-segments.ts +++ b/src/modules/financial/adapters/cnab/multipag-segments.ts @@ -8,6 +8,7 @@ // // Esta camada é ACL (ADR-0006): recebe dados já resolvidos e não conhece agregado nem repositório. import { ok, err, type Result } from '../../../../shared/primitives/result.ts'; +import { PIX_KEY_MAX_WIDTH } from '../../domain/payout/pix-key.ts'; import { alpha, blanks, @@ -387,7 +388,11 @@ const isPixInitiation = (raw: string): boolean => new RegExp(`^\\d{${String(PIX_INITIATION_WIDTH)}}$`).test(raw); // 99 posições é o TAMANHO DO CAMPO, e aqui ele é um limite real, não um alinhamento. -const PIX_KEY_WIDTH = 99; +// A largura vem do DOMÍNIO (`payout/pix-key.ts`), e não de um `99` local, pela razão da #948: o +// pré-voo precisa da mesma medida para antecipar `pix-key-unrepresentable` antes do `allocateNsa`, e +// domínio não alcança adapter. Duas constantes com o mesmo valor seriam duas réguas — e uma delas +// mudaria sozinha no dia em que o layout mudasse. +const PIX_KEY_WIDTH = PIX_KEY_MAX_WIDTH; // ─── O ISPB na modalidade Pix — ZEROS, por laudo do banco (#923) ───────────────────────────── // diff --git a/src/modules/financial/adapters/cnab/pix-initiation.ts b/src/modules/financial/adapters/cnab/pix-initiation.ts index 56a4c19f..e9a58a3e 100644 --- a/src/modules/financial/adapters/cnab/pix-initiation.ts +++ b/src/modules/financial/adapters/cnab/pix-initiation.ts @@ -1,5 +1,7 @@ import { ok, err, type Result } from '#src/shared/index.ts'; +import type { PayablePixKeyType } from '../../domain/payout/pix-key.ts'; + /* * A forma de iniciação do Pix (`G100`), derivada do tipo da chave do favorecido (#838, CA3). * @@ -46,20 +48,36 @@ export type PixInitiationError = 'remittance-pix-key-type-unsupported'; // posições o banco lê com outra régua. Se essa rota entrar um dia, entra por decisão de produto // e por um caminho próprio — não por uma linha a mais neste mapa. // -// ⚠️ `Map`, e NÃO um objeto literal, e a razão é de segurança, não de estilo. Um -// `Record` herda de `Object.prototype`, e `keyType` chega como string arbitrária: -// `record['toString']` devolveria uma FUNÇÃO, que não é `undefined` e passaria pela guarda abaixo -// como se fosse código `G100` válido. Um mapa banco→ISPB que vivia aqui ao lado escapava disso por -// ter uma guarda de forma (`^\d{3}$`) que barrava `'toString'` antes do acesso — ele saiu na #923, -// mas a lição fica. Aqui não existe forma numérica a validar, então a estrutura é que precisa não -// ter protótipo. -const INITIATION_BY_KEY_TYPE: ReadonlyMap = new Map([ - ['phone', '01'], - ['email', '02'], - ['cpf', '03'], - ['cnpj', '03'], - ['random-key', '04'], -]); +// ⚠️ `Record` PRIMEIRO, `Map` DEPOIS — e cada metade resolve um problema diferente. Perder qualquer +// uma reabre um defeito distinto. +// +// **O `Record` dá EXAUSTIVIDADE.** A lista de tipos pagáveis não vive mais +// aqui: vive em `domain/payout/pix-key.ts`, porque o pré-voo precisa dela e domínio não alcança +// adapter (#948, CA2 — mesmo arranjo de `hasRemittanceIssuer`). Com a fonte lá e o mapa aqui, faltava +// o que impede as duas de divergirem: um tipo novo entrando na lista do domínio faria o pré-voo +// aprovar uma chave que este arquivo recusaria — exatamente a divergência que a fatia fecha. `Record` +// EXIGE a chave; o `Map` literal que existia aqui aceitava faltar. +// +// **O `Map` dá SEGURANÇA DE PROTÓTIPO.** `keyType` chega como string arbitrária, e um `Record` +// consultado direto herda de `Object.prototype`: `record['toString']` devolveria uma FUNÇÃO, que não +// é `undefined` e passaria pela guarda abaixo como se fosse código `G100` válido. `new Map(entries)` +// não tem protótipo a herdar. Um mapa banco→ISPB que vivia aqui ao lado escapava disso por ter uma +// guarda de forma (`^\d{3}$`); ele saiu na #923, mas a lição fica — aqui não há forma numérica a +// validar, então a estrutura é que precisa não ter protótipo. +// +// A tradução NÃO é 1:1, e o `Record` deixa as duas assimetrias visíveis do mesmo jeito que o literal +// deixava: `cpf` e `cnpj` apontam para `03`, e não há origem para `05`. +const INITIATION_BY_PAYABLE_KEY_TYPE: Record = { + phone: '01', + email: '02', + cpf: '03', + cnpj: '03', + 'random-key': '04', +}; + +const INITIATION_BY_KEY_TYPE: ReadonlyMap = new Map( + Object.entries(INITIATION_BY_PAYABLE_KEY_TYPE), +); export const pixInitiationFor = (keyType: string): Result => { const initiation = INITIATION_BY_KEY_TYPE.get(keyType); diff --git a/src/modules/financial/domain/payout/payout-readiness.ts b/src/modules/financial/domain/payout/payout-readiness.ts index 4f3f935f..b2a6e140 100644 --- a/src/modules/financial/domain/payout/payout-readiness.ts +++ b/src/modules/financial/domain/payout/payout-readiness.ts @@ -2,6 +2,7 @@ import { immutable } from '../../../../shared/primitives/immutable.ts'; import type { PaymentMethod } from '../document/types.ts'; import { type DigitableLineError, resolveBarcode } from './digitable-line.ts'; import { decomposePayeeAccount } from './payee-account.ts'; +import { isPayablePixKeyType, pixKeyFitsField } from './pix-key.ts'; import { hasRemittanceIssuer } from './van-routes.ts'; import type { PayoutCandidate, @@ -161,10 +162,44 @@ const checkRouteData = (candidate: PayoutCandidate, route: VanRoute): RouteDataC // ⚠️ Efeito colateral DESEJADO, e que aparece na tela: some o `check-digit-mismatch` na rota Pix. // Favorecido com DV divergente era bloqueado aqui; passa a pagar, e está certo — o DV não vai no // arquivo. A régua de DV continua valendo onde o dígito é escrito, que é a transferência. - case 'pix': - return isBlank(candidate.payee?.pixKey?.key ?? null) - ? incomplete(route, missingField(route, 'pix-key').gaps) - : ready(route); + // ⚠️ AS DUAS CONDIÇÕES DO EMISSOR (#948, CA1/CA2) SÃO VERIFICADAS AQUI, e não são zelo: as duas + // recusas correspondentes vêm DEPOIS do `allocateNsa`, então cada tentativa queima um número da + // série antes de o operador descobrir por quê. A régua vem de `pix-key.ts` — a MESMA fonte que o + // emissor consome —, e é isso que impede as duas de divergirem outra vez. + // + // 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 as 99 posições do `G101`, e + // o vocabulário de tipos de chave é de `partners`, que pode crescer sem que o `G100` cresça junto. + case 'pix': { + const key = candidate.payee?.pixKey?.key ?? null; + const keyType = candidate.payee?.pixKey?.keyType ?? null; + + // Chave em branco é chave ausente: o cadastro guarda `''` com mais frequência que `null` (ver o + // CHECK do bloco bancário, em `types.ts`). E conta completa NÃO substitui a chave — quem + // escolheu PIX no lançamento paga por PIX, e trocar a rota mudaria o custo e o prazo aceitos. + // + // Sem chave, PARA AQUI em vez de acumular: as duas verificações seguintes são sobre a chave que + // não existe, e listá-las mandaria o operador corrigir o comprimento de um campo vazio. + if (isBlank(key)) return incomplete(route, missingField(route, 'pix-key').gaps); + + const gaps: PayoutGap[] = []; + + // `malformed`, e NÃO `unmappable` — desvio deliberado da letra da CA1 da #948, que dizia + // `unmappable`. 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 `unmappable` nas duas condições as tornaria indistinguíveis na + // tela, que é justamente o que a lista de lacunas por CAMPO existe para evitar. + if (!pixKeyFitsField(key ?? '')) gaps.push({ field: 'pix-key', reason: 'malformed' }); + + // `unmappable` é o encaixe exato: o cadastro tem um tipo de chave que este layout não prevê, e + // não há o que corrigir no valor — é preciso outra chave. Cobre também o `keyType` VAZIO, que o + // reader recusa derrubando a geração inteira (a CA8, que sai de graça aqui). + if (!isPayablePixKeyType(keyType ?? '')) + gaps.push({ field: 'pix-key', reason: 'unmappable' }); + + return gaps.length === 0 ? ready(route) : incomplete(route, immutable(gaps)); + } // Única rota que depende da conta estruturada — e, portanto, a única em que o desencaixe do // cadastro vira impedimento de pagamento. diff --git a/src/modules/financial/domain/payout/pix-key.ts b/src/modules/financial/domain/payout/pix-key.ts new file mode 100644 index 00000000..4147f855 --- /dev/null +++ b/src/modules/financial/domain/payout/pix-key.ts @@ -0,0 +1,90 @@ +// AS CONDIÇÕES QUE O EMISSOR DE PIX IMPÕE À CHAVE — fonte ÚNICA, pela mesma razão e no mesmo molde +// de `van-routes.ts` (#837). +// +// A #837 fechou a divergência "o pré-voo aprova e o emissor recusa" para a EXISTÊNCIA do emissor, +// criando aqui a lista de rotas que ele sabe montar. Sobrou a outra metade, que a #948 nomeia: as +// CONDIÇÕES que esse emissor impõe. O Pix reabriu a divergência por duas portas, e as duas custam +// caro porque a recusa vem DEPOIS do `allocateNsa` — cada tentativa queima um número da série: +// +// · chave que não cabe nas 99 posições do `G101` → `pix-key-unrepresentable`; +// · tipo de chave que o `G100` não prevê → `remittance-pix-key-type-unsupported`. +// +// ⚠️ VIVE NO DOMÍNIO pela seta de dependência, não por gosto — exatamente o argumento de +// `van-routes.ts`. Quem precisa da régua é `checkPayoutReadiness`, que é domínio e é chamada como +// função pura, sem deps (o que descarta também a saída por port). Adapter alcança domínio; domínio +// não alcança adapter. Então a fonte fica aqui e o emissor desce até ela — como `batch-profile.ts` +// já faz com `hasRemittanceIssuer`. +// +// ⚠️ E AQUI, AO CONTRÁRIO DE `van-routes.ts`, AS DUAS CONSTANTES SÃO PROPRIEDADE DO LAYOUT, NÃO +// ESTADO DA IMPLEMENTAÇÃO. A distinção é a que aquele arquivo pede para não se desfazer, e vale +// registrar de que lado este caiu: a lista de rotas com emissor ENCOLHE E CRESCE conforme o emissor +// avança, e caduca; estas duas não. As 99 posições do `G101` e os cinco valores do `G100` só mudam +// se o BANCO mudar o layout — e no dia em que mudarem, mudam para o pré-voo e para o emissor juntos, +// que é precisamente por isso que uma fonte só é o desenho certo. + +/** + * A largura do campo `G101` (Segmento B, colunas 128-226) na modalidade Pix. + * + * ⚠️ É LIMITE REAL, não alinhamento — e é a distinção que torna o caso perigoso. `text()` trunca por + * desenho, o que é correto para nome e endereço: cortar um sobrenome não muda o destino do dinheiro. + * Uma chave truncada muda: as 99 primeiras posições de uma chave maior são uma chave DIFERENTE, e ou + * o SPI a recusa, ou ela pertence a outro recebedor. + * + * O cadastro aceita chave bem maior que isto, então o caso é alcançável por dado perfeitamente + * legítimo — não é defesa contra entrada malformada. + */ +export const PIX_KEY_MAX_WIDTH = 99; + +/** + * Os tipos de chave que o emissor sabe traduzir para o domínio `G100` (Forma de Iniciação). + * + * O `G100` é **fechado**: o manual enumera os valores e não há sexto possível — é o que separa esta + * allow-list legítima da que seria indevida para o `P011`, cujo domínio vem do dicionário do Bacen e + * se declara parcial. + * + * ⚠️ A LISTA É DE TIPOS DE CHAVE, NÃO DE CÓDIGOS `G100`, e a assimetria é deliberada: `cpf` e `cnpj` + * colapsam no mesmo código `03`, porque o banco não distingue pessoa física de jurídica na forma de + * iniciação. O domínio não precisa saber disso — precisa saber quais tipos SÃO PAGÁVEIS. A tradução + * para o código vive no adapter (`pix-initiation.ts`), que é onde ela morre quando o layout mudar, e + * o compilador cobra que ele cubra exatamente esta lista. + * + * ⚠️ O `05` (Dados Bancários) não tem entrada aqui, e a ausência é a mesma do adapter: ele é o Pix + * iniciado por agência/conta, que não é "uma chave de outro tipo" — é outro caminho de pagamento, em + * que o bloco de 99 posições deixa de carregar chave. Se entrar um dia, entra por decisão de produto. + */ +export const PAYABLE_PIX_KEY_TYPES = ['phone', 'email', 'cpf', 'cnpj', 'random-key'] as const; + +/** + * O tipo de chave estreitado ao que é pagável. + * + * É o que dá a rede do compilador ao adapter: `pix-initiation.ts` monta seu mapa a partir de um + * `Record`, e `Record` **exige exaustividade** — no dia em que um tipo + * entrar nesta lista, o typecheck aponta o mapa que ficou sem o código correspondente, em vez de o + * pré-voo aprovar um tipo que o emissor recusaria. + */ +export type PayablePixKeyType = (typeof PAYABLE_PIX_KEY_TYPES)[number]; + +// `ReadonlySet`, e não `Set`: o `keyType` chega como `string` arbitrária, +// porque o `financial` mantém o tipo OPACO de propósito (`types.ts`) — quem valida o vocabulário é +// `partners`, no `createPixKey`. Um `Set` do tipo estreito recusaria o argumento por tipo e +// transformaria a pergunta que esta função existe para responder em erro de compilação. Mesmo padrão +// de `withIssuer` em `van-routes.ts`. +const payableKeyTypes: ReadonlySet = new Set(PAYABLE_PIX_KEY_TYPES); + +/** + * "O emissor sabe traduzir este tipo de chave?" — e SÓ isso. + * + * Não julga se a chave é válida, se existe no DICT ou se pertence ao favorecido: nada disso é + * verificável aqui. Quem confere a titularidade é o PSP do recebedor, na liquidação, e a resposta + * volta no `G059` (`PF`/`PG`). + */ +export const isPayablePixKeyType = (keyType: string): boolean => payableKeyTypes.has(keyType); + +/** + * "Esta chave cabe no campo do arquivo?" + * + * Recebe a chave JÁ como o cadastro a guarda. O `trim` espelha o do emissor + * (`segmentBPix`) de propósito: se as duas réguas medirem coisas diferentes, volta a divergência + * que esta fatia existe para fechar. + */ +export const pixKeyFitsField = (key: string): boolean => key.trim().length <= PIX_KEY_MAX_WIDTH; diff --git a/tests/modules/financial/domain/payout/payout-readiness.test.ts b/tests/modules/financial/domain/payout/payout-readiness.test.ts index 986a238c..705c7efa 100644 --- a/tests/modules/financial/domain/payout/payout-readiness.test.ts +++ b/tests/modules/financial/domain/payout/payout-readiness.test.ts @@ -112,14 +112,22 @@ describe('checkPayoutReadiness — a forma de pagamento decide o que o arquivo e }); }); -// ⚠️ ESTE BLOCO SE CHAMAVA "PIX exige a chave, e só a chave", e a mudança de nome é o registro de -// uma premissa que caiu (#838). O "e só a chave" vinha da decisão da P.O. de 13/08 (#708) — "PIX -// paga por chave, não olha agência ou conta" —, que descreve o NEGÓCIO corretamente e o ARQUIVO não: -// o golden do banco traz banco, agência, DV, conta e DV do favorecido preenchidos no Segmento A, com -// o layout (p. 39) marcando os quatro como obrigatórios. +// ⚠️ ESTE BLOCO JÁ TROCOU DE NOME DUAS VEZES, e o vaivém é o registro mais útil que ele carrega. // -// Quem reverter isto lendo a #708 reabre a divergência que a #837 fechou, pelo outro lado: o -// pré-voo aprovaria um título que o emissor não consegue montar. +// Era "PIX exige a chave, e só a chave" (#708: *"PIX paga por chave, não olha agência ou conta"*). +// Virou "exige a chave E o bloco bancário" (#838), porque o golden do banco traz banco, agência, DV, +// conta e DV preenchidos no Segmento A e o layout marca os campos com asterisco. Voltou a ser "e só +// ela" (#945), quando o Bradesco arbitrou por escrito que essas posições podem sair ZERADAS no Pix +// iniciado por chave. +// +// A lição que sobra, e é a razão de este parágrafo continuar aqui: **golden prova como um arquivo +// foi montado, não o que o ERP deve coletar**, e o asterisco do manual significa "merece atenção +// especial", não "obrigatório" (legenda, p. 7 — o manual chega a marcar `*G009` e escrever "Campo +// Não Obrigatório" na descrição). Quem reabrir a exigência com base numa dessas duas evidências +// estará refazendo o mesmo salto. +// +// O que NÃO oscilou, e a #948 reforça: o pré-voo não pode aprovar o que o emissor recusa. As duas +// condições da CHAVE (comprimento e tipo) são verificadas aqui justamente por isso. const pixPayee = (): PayeePaymentTarget => target({ ...fullAccount(), pixKey: { keyType: 'email', key: 'a@b.com' } }); @@ -129,16 +137,20 @@ describe('checkPayoutReadiness — PIX exige a chave, e só ela (#945)', () => { assert.equal(r.status, whenDataIsGood('pix')); }); - // O `keyType` NÃO participa da decisão de aptidão — ele viaja para quem emite o registro. Um - // tipo desconhecido aqui não pode reprovar o título: quem valida o conjunto de tipos é - // `partners`, no `createPixKey`, e duplicar essa lista seria criar a segunda fonte da verdade. + // ⚠️ INVERTIDO PELA #948 (CA2), e o teste anterior dizia o contrário — "não julga o tipo da chave, + // apenas a existência dela" —, apoiado em duas premissas que eram boas e não bastavam: o + // vocabulário de tipos é 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: a + // lista de tipos PAGÁVEIS vive em `domain/payout/pix-key.ts` e o emissor desce até ela. Não são + // duas listas — é uma, consumida dos dois lados, no molde de `van-routes.ts`. // - // ⚠️ Continua verdadeiro depois da #838, e a distinção é fina: o `keyType` fora do domínio `G100` - // faz o EMISSOR recusar (`remittance-pix-key-type-unsupported`), não o pré-voo. São perguntas - // diferentes — "o cadastro está completo?" e "sei emitir isto?" —, e é exatamente a separação que - // a #837 estabeleceu. - it('não julga o tipo da chave, apenas a existência dela', () => { - for (const keyType of ['email', 'cpf', 'random-key', 'algo-que-partners-ainda-nao-tem']) { + // E o que derrubou a separação "o cadastro está completo?" × "sei emitir isto?" foi o CUSTO: a + // recusa do emissor vem DEPOIS do `allocateNsa`, então deixá-la para a geração queima um número da + // série a cada tentativa. A #837 estabeleceu que as duas perguntas não podem discordar; esta é a + // segunda metade dela, sobre as CONDIÇÕES do emissor e não sobre a existência dele. + it('aceita todo tipo de chave que o emissor sabe traduzir', () => { + for (const keyType of ['phone', 'email', 'cpf', 'cnpj', 'random-key']) { const r = checkPayoutReadiness( candidate({ paymentMethod: 'PIX', @@ -149,6 +161,102 @@ describe('checkPayoutReadiness — PIX exige a chave, e só ela (#945)', () => { } }); + it('recusa o tipo de chave que o G100 não prevê, em vez de deixar o emissor queimar o NSA', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ + ...fullAccount(), + pixKey: { keyType: 'algo-que-o-G100-nao-tem', key: 'x' }, + }), + }), + ); + + assert.equal(r.status, 'incomplete'); + assert.deepEqual(fieldsOf(r), ['pix-key']); + // `unmappable` — não há o que corrigir no VALOR: é preciso outra chave. + assert.equal(reasonFor(r, 'pix-key'), 'unmappable'); + }); + + // CA8: `keyType` vazio cai na mesma régua, e o desfecho é o mesmo. Hoje o reader o recusa + // derrubando a geração INTEIRA (contrato tudo-ou-nada), com o pré-voo tendo aprovado a linha. + it('trata tipo de chave vazio como não suportado', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ ...fullAccount(), pixKey: { keyType: '', key: 'x' } }), + }), + ); + + assert.equal(r.status, 'incomplete'); + assert.equal(reasonFor(r, 'pix-key'), 'unmappable'); + }); + + // CA1. Alcançável por cadastro LEGÍTIMO: o campo `G101` tem 99 posições e o cadastro aceita mais. + // + // ⚠️ O perigo não é o arquivo ser recusado — é `text()` TRUNCAR por desenho. As 99 primeiras + // posições de uma chave maior são uma chave DIFERENTE, e ou o SPI a recusa, ou ela pertence a + // outro recebedor. O emissor já barra isso; o que faltava era barrar antes de o NSA ser alocado. + it('recusa a chave que não cabe nas 99 posições do G101', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ ...fullAccount(), pixKey: { keyType: 'email', key: 'k'.repeat(100) } }), + }), + ); + + assert.equal(r.status, 'incomplete'); + assert.deepEqual(fieldsOf(r), ['pix-key']); + // `malformed`, e não `unmappable`: a chave É conversível e É uma chave — o que ela não é, é + // representável no campo. Ver a nota em `payout-readiness.ts`, `case 'pix'`. + assert.equal(reasonFor(r, 'pix-key'), 'malformed'); + }); + + it('aceita a chave de exatamente 99 posições — o limite é inclusivo', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ ...fullAccount(), pixKey: { keyType: 'email', key: 'k'.repeat(99) } }), + }), + ); + + assert.equal(r.status, whenDataIsGood('pix')); + }); + + // As duas condições ACUMULAM: quem tem chave longa demais E de tipo não suportado precisa ver as + // duas de uma vez, não uma volta ao cadastro por vez. Mesma disciplina do boleto. + it('acumula as duas condições da chave', () => { + const r = checkPayoutReadiness( + candidate({ + paymentMethod: 'PIX', + payee: target({ + ...fullAccount(), + pixKey: { keyType: 'nao-existe', key: 'k'.repeat(100) }, + }), + }), + ); + + assert.equal(r.status, 'incomplete'); + assert.deepEqual( + gapsOf(r) + .map((g) => g.reason) + .sort(), + ['malformed', 'unmappable'], + ); + }); + + // Sem chave, o pré-voo PARA — listar "comprimento" e "tipo" de um campo vazio mandaria o operador + // corrigir o que não existe. A pendência é uma só: cadastrar a chave. + it('não acumula condições da chave quando a chave nem existe', () => { + const r = checkPayoutReadiness( + candidate({ paymentMethod: 'PIX', payee: target({ ...fullAccount(), pixKey: null }) }), + ); + + assert.equal(r.status, 'incomplete'); + assert.deepEqual(fieldsOf(r), ['pix-key']); + assert.equal(reasonFor(r, 'pix-key'), 'missing'); + }); + // Chave presente porém em branco é chave ausente — o cadastro guarda `''` com mais frequência // que `null` (CHECK do bloco bancário). it('trata chave em branco como ausente', () => { diff --git a/tests/modules/financial/domain/payout/pix-key-single-source.test.ts b/tests/modules/financial/domain/payout/pix-key-single-source.test.ts new file mode 100644 index 00000000..ea71ccb7 --- /dev/null +++ b/tests/modules/financial/domain/payout/pix-key-single-source.test.ts @@ -0,0 +1,103 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { isErr, isOk } from '#src/shared/index.ts'; +import { + PAYABLE_PIX_KEY_TYPES, + PIX_KEY_MAX_WIDTH, + isPayablePixKeyType, + pixKeyFitsField, +} from '#src/modules/financial/domain/payout/pix-key.ts'; +import { pixInitiationFor } from '#src/modules/financial/adapters/cnab/pix-initiation.ts'; +import { segmentBPix } from '#src/modules/financial/adapters/cnab/multipag-segments.ts'; + +/* + * CA3 da #948 — a rede que impede o pré-voo e o emissor de voltarem a discordar sobre o Pix. + * + * A #837 fechou esta classe de defeito uma vez, para a EXISTÊNCIA do emissor. O Pix a reabriu pelas + * CONDIÇÕES dele: duas recusas do montador que o pré-voo não consultava, e que chegam DEPOIS do + * `allocateNsa` — cada tentativa queimando um número da série. + * + * ⚠️ ESTE TESTE MEDE OS DOIS LADOS DE FATO, e é o que o separa de um teste de tipos. Metade das + * garantias aqui já é do compilador — o `Record` de `pix-initiation.ts` + * não compila com um tipo faltando, e `PIX_KEY_WIDTH` é literalmente `PIX_KEY_MAX_WIDTH`. Mas + * compilador só cobra o que está LIGADO: no dia em que alguém reescrever o mapa com chaves soltas ou + * voltar a escrever `99` no montador, a compilação segue verde e a divergência volta em silêncio. + * Por isso cada caso abaixo EXECUTA as duas pontas e compara desfechos, em vez de inspecionar tipos. + */ + +const PAYEE = { name: 'FORNECEDOR TESTE', documentType: '2', document: '98765432000198' } as const; + +const buildSegmentB = (key: string, initiation: string) => + segmentBPix({ + bankCode: '237', + batchNumber: 1, + recordNumber: 2, + payee: PAYEE, + initiation, + pixKey: key, + }); + +describe('#948 CA3 — o pré-voo e o emissor decidem o Pix pela mesma régua', () => { + it('todo tipo que o pré-voo aprova, o emissor sabe traduzir', () => { + for (const keyType of PAYABLE_PIX_KEY_TYPES) { + assert.equal(isPayablePixKeyType(keyType), true, `${keyType} deveria ser pagável`); + + const initiation = pixInitiationFor(keyType); + assert.ok( + isOk(initiation), + `o pré-voo aprova '${keyType}' e o emissor recusa — é a divergência que a #948 fecha`, + ); + assert.match(initiation.value, /^\d{2}$/, `o G100 de '${keyType}' deve ter dois dígitos`); + } + }); + + it('todo tipo que o pré-voo recusa, o emissor também recusa', () => { + for (const keyType of ['', 'algo-que-o-G100-nao-tem', 'toString', 'constructor', '05']) { + assert.equal(isPayablePixKeyType(keyType), false, `${keyType} não deveria ser pagável`); + assert.ok( + isErr(pixInitiationFor(keyType)), + `o pré-voo recusa '${keyType}' e o emissor aceita — o arquivo sairia com iniciação inventada`, + ); + } + }); + + // ⚠️ `'toString'` e `'constructor'` acima não são zelo decorativo: um `Record` consultado direto + // devolveria uma FUNÇÃO para essas chaves, que não é `undefined` e passaria pela guarda do emissor + // como se fosse código `G100` válido. É a razão de o mapa ser `Map` e não objeto, e o caso fica + // fixado aqui porque o defeito só aparece se alguém trocar a estrutura de volta. + + it('a chave que o pré-voo aprova pelo comprimento, o emissor monta', () => { + for (const size of [1, 36, PIX_KEY_MAX_WIDTH]) { + const key = 'k'.repeat(size); + assert.equal(pixKeyFitsField(key), true, `chave de ${String(size)} deveria caber`); + assert.ok( + isOk(buildSegmentB(key, '04')), + `o emissor recusou chave de ${String(size)} posições`, + ); + } + }); + + it('a chave que o pré-voo recusa pelo comprimento, o emissor também recusa', () => { + const key = 'k'.repeat(PIX_KEY_MAX_WIDTH + 1); + + assert.equal(pixKeyFitsField(key), false); + + const r = buildSegmentB(key, '04'); + assert.ok( + isErr(r), + 'o emissor aceitou chave que não cabe — ela seria TRUNCADA, virando outra chave', + ); + assert.equal(r.error, 'pix-key-unrepresentable'); + }); + + // O `trim` tem de ser o MESMO dos dois lados. Se o pré-voo medisse a chave crua e o emissor a + // medisse aparada (ou o contrário), uma chave de 99 posições com espaços em volta seria aprovada + // por um e recusada pelo outro — divergência de UMA posição, invisível em revisão. + it('as duas pontas medem a chave depois do mesmo trim', () => { + const key = ` ${'k'.repeat(PIX_KEY_MAX_WIDTH)} `; + + assert.equal(pixKeyFitsField(key), true); + assert.ok(isOk(buildSegmentB(key, '04'))); + }); +}); From 198aeb848a8afdec1d227db982cc9ceed4a45eae Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 11:02:51 -0300 Subject: [PATCH 03/13] =?UTF-8?q?fix(financial):=20os=20campos=20do=20cede?= =?UTF-8?q?nte=20no=20header=20ganham=20fonte,=20e=20a=20ag=C3=AAncia=20pa?= =?UTF-8?q?ra=20de=20ser=20corrompida=20(#856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/etl/financial/mapper.ts | 26 +- .../financial/adapters/http/error-mapping.ts | 14 + src/modules/financial/adapters/http/plugin.ts | 3 + .../financial/adapters/http/schemas.ts | 15 + .../mappers/cedente-account.mapper.ts | 7 + .../migrations/mysql/0056_daily_firebrand.sql | 1 + .../migrations/mysql/meta/0056_snapshot.json | 2788 +++++++++++++++++ .../migrations/mysql/meta/_journal.json | 7 + .../remittance-payment-reader.drizzle.ts | 29 +- .../adapters/persistence/schemas/mysql.ts | 12 + .../use-cases/create-cedente-account.ts | 2 + .../use-cases/edit-cedente-account.ts | 43 +- .../use-cases/generate-remittance.ts | 41 +- .../domain/cedente/cedente-account.ts | 7 + .../domain/cedente/remittance-eligibility.ts | 98 +- src/modules/financial/domain/cedente/types.ts | 12 + .../financial/domain/payout/inscription.ts | 84 + .../financial/domain/payout/payee-account.ts | 10 +- tests/etl/financial/mapper.test.ts | 46 +- .../http/error-classification.test.ts | 39 + .../http/financial-cedente.http.test.ts | 64 + .../http/strict-response-schemas.test.ts | 3 + .../mappers/cedente-account.mapper.test.ts | 33 + .../use-cases/edit-cedente-account.test.ts | 71 +- .../use-cases/generate-remittance.test.ts | 170 +- .../cedente/remittance-eligibility.test.ts | 136 +- .../domain/payout/inscription.test.ts | 88 + 27 files changed, 3802 insertions(+), 47 deletions(-) create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/0056_daily_firebrand.sql create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/meta/0056_snapshot.json create mode 100644 src/modules/financial/domain/payout/inscription.ts create mode 100644 tests/modules/financial/domain/payout/inscription.test.ts diff --git a/scripts/etl/financial/mapper.ts b/scripts/etl/financial/mapper.ts index 805f0999..0c3aed1e 100644 --- a/scripts/etl/financial/mapper.ts +++ b/scripts/etl/financial/mapper.ts @@ -12,6 +12,7 @@ import { type Result, ok, err } from '#src/shared/primitives/result.ts'; import type { DocumentType, PaymentMethod } from '#src/modules/financial/domain/document/types.ts'; +import { splitCheckDigit } from '#src/modules/financial/domain/payout/payee-account.ts'; import type { LegacyAccountRow, LegacyPayableRow } from '../legacy/rows.ts'; import type { QuarantineReason } from '../quarantine/reason.ts'; import { EXCLUDED_PAYABLE_LEGACY_IDS } from './exclusions.ts'; @@ -28,6 +29,8 @@ export type CedenteAccountPlan = Readonly<{ input: Readonly<{ bankCode: string; agency: string; + // #856 — o DV da agência, separado do número. Ausente quando o legado não trazia separador. + agencyDigit?: string; accountNumber: string; accountDigit: string; convenio: string; @@ -54,15 +57,32 @@ export const mapLegacyAccountRow = ( errors.push({ tag: 'EnumUnknown', field: 'bank', attempted: row.bank }); } + // F7: a agência legada vem com o DV EMBUTIDO, no formato `NNNN-D`. + // + // ⚠️ ELA ERA GRAVADA INTEIRA, e isso corrompia toda remessa daquela conta em silêncio (#856). O + // emissor escreve `digits(agency, 5)`, que faz `replace(/\D/g,'')` antes do pad: `1234-5` virava + // `12345` nas posições 053-057, onde o banco espera `01234`. Cinco dígitos, cabe no campo, nenhum + // gate acusa — e o arquivo vai ao banco apontando outra agência. O comentário anterior dizia que o + // dígito ia "no campo próprio"; não ia, porque campo próprio não existia. Agora existe. + // + // A decomposição é `splitCheckDigit`, do domínio — a MESMA que lê a conta do favorecido. Uma + // segunda gramática aqui divergiria da de lá no dia em que uma das duas mudasse. + // ⚠️ O QUE NÃO DECOMPÕE NÃO VAI PARA QUARENTENA, e a escolha é a lição da #879. A conta-cedente + // serve à CONCILIAÇÃO sem agência válida; barrar a migração por um campo que só a remessa exige + // repetiria o beco do `'LEGADO'` — conta ausente do sistema, sem via de correção pela tela. Segue + // como veio, e quem recusa é `checkCedenteAgency` na hora de gerar, com lacuna nomeada, mensagem + // PT-BR dizendo onde corrigir, e ANTES de queimar NSA. + const agencyParts = splitCheckDigit(row.agency.trim()); + if (errors.length > 0 || bankCode === undefined) return err(errors); return ok({ legacyId: row.id, input: { bankCode, - // F7: agência legada vem com DV embutido ('0288-7') E dv separado — preservamos - // a agência como veio (domínio só exige não-blank) e o dv no campo próprio. - agency: row.agency, + agency: agencyParts?.base ?? row.agency, + // `null` é "o legado não trouxe DV", e vira ausência — nunca `'0'`, que é um dígito afirmado. + ...(agencyParts?.digit != null ? { agencyDigit: agencyParts.digit } : {}), accountNumber: row.accountNumber, accountDigit: row.dv, // D6: o convênio real do Bradesco não existe no legado, e o correto é nascer VAZIO. diff --git a/src/modules/financial/adapters/http/error-mapping.ts b/src/modules/financial/adapters/http/error-mapping.ts index ee46b8b5..dd156b49 100644 --- a/src/modules/financial/adapters/http/error-mapping.ts +++ b/src/modules/financial/adapters/http/error-mapping.ts @@ -271,6 +271,20 @@ const SLUG_MESSAGES: Record = { 'O convênio da conta bancária selecionada tem mais de 6 dígitos. O banco lê apenas os 6 primeiros e descartaria o restante, processando a remessa sob outro convênio. Confirme o número junto ao banco e corrija-o no cadastro da conta.', 'cedente-convenio-already-set': 'Esta conta já tem convênio. Ele identifica o contrato junto ao banco e não pode ser trocado.', + // #856 · #859. Também campo do CADASTRO da conta. A mensagem do malformado diz o que fazer com o + // dígito de propósito: o caso real é o operador ter digitado `0288-2` num campo que é só a agência, + // e mandá-lo "corrigir a agência" sem dizer onde o dígito vai o faria apagar o dado. + 'cedente-agency-missing': + 'A conta bancária selecionada está sem a agência. Informe-a no cadastro da conta para gerar remessas.', + 'cedente-agency-malformed': + 'A agência da conta bancária selecionada não é um número de até 5 dígitos. Informe apenas os dígitos da agência, sem traço nem ponto — o dígito verificador tem campo próprio no cadastro.', + // #856 CA3. As duas frases terminam em AÇÕES opostas, e é a ação que o operador precisa ler: a + // primeira se resolve no cadastro, a segunda NÃO se resolve em lugar nenhum do ERP — o dado está + // certo e o layout do banco é que é anterior ao CNPJ alfanumérico (ADR-0044). + 'cedente-inscription-missing': + 'A conta bancária selecionada está sem o CNPJ/CPF da organização. Informe-o no cadastro da conta para gerar remessas.', + 'cedente-inscription-alphanumeric': + 'O CNPJ da organização tem letras (formato válido desde 07/2026), e o layout do banco ainda declara o campo como numérico. Nada a corrigir no cadastro: a forma de emissão precisa ser confirmada junto ao Bradesco antes de gerar esta remessa.', 'document-not-found': 'Documento não encontrado.', 'timeline-document-not-found': 'Documento não encontrado.', 'document-version-conflict': diff --git a/src/modules/financial/adapters/http/plugin.ts b/src/modules/financial/adapters/http/plugin.ts index 161f0c1b..08796b04 100644 --- a/src/modules/financial/adapters/http/plugin.ts +++ b/src/modules/financial/adapters/http/plugin.ts @@ -350,6 +350,7 @@ const cedenteAccountToDto = (a: CedenteAccount): CedenteAccountResponseDto => ({ type: a.type ?? null, typeLabel: a.typeLabel ?? null, agency: a.agency, + agencyDigit: a.agencyDigit ?? null, accountNumber: a.accountNumber, accountDigit: a.accountDigit, convenio: a.convenio, @@ -2089,6 +2090,7 @@ const financialRoutes = accountNumber: b.accountNumber, accountDigit: b.accountDigit, document: b.document, + ...(b.agencyDigit !== undefined ? { agencyDigit: b.agencyDigit } : {}), ...(b.typeLabel !== undefined ? { typeLabel: b.typeLabel } : {}), ...(b.bankName !== undefined ? { bankName: b.bankName } : {}), ...(b.convenio !== undefined ? { convenio: b.convenio } : {}), @@ -2306,6 +2308,7 @@ const financialRoutes = ...(b.convenio !== undefined ? { convenio: b.convenio } : {}), ...(b.bankCode !== undefined ? { bankCode: b.bankCode } : {}), ...(b.agency !== undefined ? { agency: b.agency } : {}), + ...(b.agencyDigit !== undefined ? { agencyDigit: b.agencyDigit } : {}), ...(b.accountNumber !== undefined ? { accountNumber: b.accountNumber } : {}), ...(b.accountDigit !== undefined ? { accountDigit: b.accountDigit } : {}), ...(b.type !== undefined ? { type: b.type } : {}), diff --git a/src/modules/financial/adapters/http/schemas.ts b/src/modules/financial/adapters/http/schemas.ts index 39e2ab52..0ae16fd5 100644 --- a/src/modules/financial/adapters/http/schemas.ts +++ b/src/modules/financial/adapters/http/schemas.ts @@ -848,6 +848,14 @@ export const createCedenteAccountBodySchema = z.object({ // #206: texto livre p/ identificar conta `outro`/`cartao` (opcional). typeLabel: z.string().min(1).max(120).optional(), agency: z.string().min(1).max(10), + // #856 · #859 — DV da agência, posição 058 do header CNAB. OPCIONAL porque a agência pode não ter + // DV; separado de `agency` porque o emissor remove o separador antes de gravar as posições + // 053-057, e `0288-2` num campo só vira `02882` onde o banco espera `00288`. + agencyDigit: z + .string() + .max(2) + .optional() + .meta({ description: 'Dígito verificador da agência (posição 058 do CNAB). Sem separador.' }), accountNumber: z.string().min(1).max(20), accountDigit: z.string().max(2), convenio: z.string().max(20).optional(), @@ -865,6 +873,9 @@ export const editCedenteAccountBodySchema = z.object({ convenio: z.string().min(1).max(20).optional(), bankCode: z.string().min(1).max(10).optional(), agency: z.string().min(1).max(10).optional(), + // #856: preenchível na conta já cadastrada, que nasceu sem DV porque não havia coluna. TROCAR um + // DV já definido continua caindo na trava de dado bancário (FR-008) — ver `edit-cedente-account`. + agencyDigit: z.string().max(2).optional(), accountNumber: z.string().min(1).max(20).optional(), accountDigit: z.string().max(2).optional(), type: accountTypeSchema.optional(), @@ -885,6 +896,10 @@ export const cedenteAccountResponseSchema = z type: z.string().nullable(), typeLabel: z.string().nullable(), agency: z.string(), + // Presente na leitura para o front poder exibir o cadastro COMPLETO na edição (#856). Sem ele, + // a tela reabre com 4 dígitos, marca a agência como incompleta e desabilita o Salvar — o beco + // medido em produção (#942/#943). + agencyDigit: z.string().nullable(), accountNumber: z.string(), accountDigit: z.string(), convenio: z.string(), diff --git a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts index 24be7768..cd1bdea9 100644 --- a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts +++ b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts @@ -26,6 +26,7 @@ export const toRow = (account: CedenteAccount): NewCedenteAccountRow => ({ id: account.id, bankCode: account.bankCode, agency: account.agency, + agencyDigit: account.agencyDigit ?? null, accountNumber: account.accountNumber, accountDigit: account.accountDigit, convenio: account.convenio, @@ -64,6 +65,12 @@ export const toDomain = ( document: row.document, status, nextNsa: row.nextNsa, + // `''` gravado por uma versão anterior colapsa em ausente aqui também — a re-hidratação não + // passa pelo construtor, e sem esta linha uma linha antiga com string vazia voltaria como + // "dígito definido", trancando o preenchimento pela edição. + ...(row.agencyDigit !== null && row.agencyDigit.trim() !== '' + ? { agencyDigit: row.agencyDigit } + : {}), ...(row.type !== null ? { type: row.type as AccountType } : {}), ...(row.typeLabel !== null ? { typeLabel: row.typeLabel } : {}), ...(row.nickname !== null ? { nickname: row.nickname } : {}), diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/0056_daily_firebrand.sql b/src/modules/financial/adapters/persistence/migrations/mysql/0056_daily_firebrand.sql new file mode 100644 index 00000000..d3478590 --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/0056_daily_firebrand.sql @@ -0,0 +1 @@ +ALTER TABLE `fin_cedente_accounts` ADD `agency_digit` varchar(2); \ No newline at end of file diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/0056_snapshot.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0056_snapshot.json new file mode 100644 index 00000000..9bcec4e2 --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0056_snapshot.json @@ -0,0 +1,2788 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "7cdf3f98-08ac-4558-9b91-e8f5b40c2c5c", + "prevId": "9e65df14-6fb4-47b4-89fd-1186f2fce215", + "tables": { + "fin_bank_statements": { + "name": "fin_bank_statements", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_format": { + "name": "file_format", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closing_balance_cents": { + "name": "closing_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_bank_statements_debit_account_ref_idx": { + "name": "fin_bank_statements_debit_account_ref_idx", + "columns": ["debit_account_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_bank_statements_id": { + "name": "fin_bank_statements_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_bank_statements_file_format_chk": { + "name": "fin_bank_statements_file_format_chk", + "value": "`fin_bank_statements`.`file_format` IN ('OFX','CSV','PDF')" + } + } + }, + "fin_categories": { + "name": "fin_categories", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group": { + "name": "group", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_id": { + "name": "cost_center_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_categories_group_name_idx": { + "name": "fin_categories_group_name_idx", + "columns": ["group", "name"], + "isUnique": false + }, + "fin_categories_active_idx": { + "name": "fin_categories_active_idx", + "columns": ["active"], + "isUnique": false + }, + "fin_categories_parent_id_idx": { + "name": "fin_categories_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "fin_categories_cost_center_id_idx": { + "name": "fin_categories_cost_center_id_idx", + "columns": ["cost_center_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_categories_id": { + "name": "fin_categories_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_categories_group_chk": { + "name": "fin_categories_group_chk", + "value": "`fin_categories`.`group` IN ('despesa','receita','ajuste')" + } + } + }, + "fin_cedente_accounts": { + "name": "fin_cedente_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_code": { + "name": "bank_code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency_digit": { + "name": "agency_digit", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_digit": { + "name": "account_digit", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "convenio": { + "name": "convenio", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_nsa": { + "name": "next_nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type_label": { + "name": "type_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_date": { + "name": "opening_balance_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_cedente_accounts_natural_key_uq": { + "name": "fin_cedente_accounts_natural_key_uq", + "columns": ["bank_code", "agency", "account_number", "account_digit"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cedente_accounts_id": { + "name": "fin_cedente_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_cedente_accounts_status_chk": { + "name": "fin_cedente_accounts_status_chk", + "value": "`fin_cedente_accounts`.`status` IN ('Active','Closed')" + }, + "fin_cedente_accounts_next_nsa_chk": { + "name": "fin_cedente_accounts_next_nsa_chk", + "value": "`fin_cedente_accounts`.`next_nsa` >= 1" + }, + "fin_cedente_accounts_type_chk": { + "name": "fin_cedente_accounts_type_chk", + "value": "`fin_cedente_accounts`.`type` IS NULL OR `fin_cedente_accounts`.`type` IN ('corrente','poupanca','investimento','cartao','outro')" + } + } + }, + "fin_cost_centers": { + "name": "fin_cost_centers", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "fin_cost_centers_code_idx": { + "name": "fin_cost_centers_code_idx", + "columns": ["code"], + "isUnique": false + }, + "fin_cost_centers_active_idx": { + "name": "fin_cost_centers_active_idx", + "columns": ["active"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cost_centers_id": { + "name": "fin_cost_centers_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_document_timeline": { + "name": "fin_document_timeline", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_ref": { + "name": "actor_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tl_doc_time": { + "name": "idx_fin_tl_doc_time", + "columns": ["document_id", "occurred_at"], + "isUnique": false + }, + "idx_fin_tl_target": { + "name": "idx_fin_tl_target", + "columns": ["target_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_document_timeline_document_id_fk": { + "name": "fin_document_timeline_document_id_fk", + "tableFrom": "fin_document_timeline", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_document_timeline_id": { + "name": "fin_document_timeline_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "ck_fin_tl_target_kind": { + "name": "ck_fin_tl_target_kind", + "value": "`fin_document_timeline`.`target_kind` IN ('Document','Payable')" + }, + "ck_fin_tl_event_type": { + "name": "ck_fin_tl_event_type", + "value": "`fin_document_timeline`.`event_type` IN ('DocumentSaved','PayableApproved','ApprovalUndone','DocumentDraftSaved','PayableManuallyPaid')" + } + } + }, + "fin_documents": { + "name": "fin_documents", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "series": { + "name": "series", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payee_kind": { + "name": "payee_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gross_value": { + "name": "gross_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_discounts": { + "name": "source_discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "discounts": { + "name": "discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "penalty": { + "name": "penalty", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "interest": { + "name": "interest", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "net_value": { + "name": "net_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key": { + "name": "access_key", + "type": "varchar(44)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competencia": { + "name": "competencia", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_bucket": { + "name": "source_file_bucket", + "type": "varchar(63)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_key": { + "name": "source_file_key", + "type": "varchar(1024) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_hash_sha256": { + "name": "source_file_hash_sha256", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_size_bytes": { + "name": "source_file_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_mime": { + "name": "source_file_mime", + "type": "varchar(127)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_by_ocr": { + "name": "read_by_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ocr_original_value": { + "name": "ocr_original_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "divergence_detected": { + "name": "divergence_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approver_ref": { + "name": "approver_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_documents_supplier_ref_idx": { + "name": "fin_documents_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_documents_status_idx": { + "name": "fin_documents_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_documents_due_date_idx": { + "name": "fin_documents_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_documents_issue_date_idx": { + "name": "fin_documents_issue_date_idx", + "columns": ["issue_date"], + "isUnique": false + }, + "fin_documents_doc_number_idx": { + "name": "fin_documents_doc_number_idx", + "columns": ["document_number"], + "isUnique": false + }, + "fin_documents_legacy_id_uq": { + "name": "fin_documents_legacy_id_uq", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_documents_id": { + "name": "fin_documents_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_documents_type_chk": { + "name": "fin_documents_type_chk", + "value": "`fin_documents`.`type` IS NULL OR `fin_documents`.`type` IN ('NFS-e','DANFE','RPA','Fatura','Boleto','Recibo','Imposto')" + }, + "fin_documents_payment_method_chk": { + "name": "fin_documents_payment_method_chk", + "value": "`fin_documents`.`payment_method` IS NULL OR `fin_documents`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_documents_payee_kind_chk": { + "name": "fin_documents_payee_kind_chk", + "value": "`fin_documents`.`payee_kind` IS NULL OR `fin_documents`.`payee_kind` IN ('supplier','financier','act','collaborator')" + }, + "fin_documents_status_chk": { + "name": "fin_documents_status_chk", + "value": "`fin_documents`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_documents_gross_value_chk": { + "name": "fin_documents_gross_value_chk", + "value": "`fin_documents`.`gross_value` IS NULL OR `fin_documents`.`gross_value` >= 0" + }, + "fin_documents_net_value_chk": { + "name": "fin_documents_net_value_chk", + "value": "`fin_documents`.`net_value` IS NULL OR `fin_documents`.`net_value` >= 0" + }, + "fin_documents_source_discounts_chk": { + "name": "fin_documents_source_discounts_chk", + "value": "`fin_documents`.`source_discounts` >= 0" + }, + "fin_documents_discounts_chk": { + "name": "fin_documents_discounts_chk", + "value": "`fin_documents`.`discounts` >= 0" + }, + "fin_documents_penalty_chk": { + "name": "fin_documents_penalty_chk", + "value": "`fin_documents`.`penalty` >= 0" + }, + "fin_documents_interest_chk": { + "name": "fin_documents_interest_chk", + "value": "`fin_documents`.`interest` >= 0" + }, + "fin_documents_version_chk": { + "name": "fin_documents_version_chk", + "value": "`fin_documents`.`version` >= 0" + }, + "fin_documents_source_file_all_or_none_chk": { + "name": "fin_documents_source_file_all_or_none_chk", + "value": "(`fin_documents`.`source_file_bucket` IS NULL AND `fin_documents`.`source_file_key` IS NULL AND `fin_documents`.`source_file_hash_sha256` IS NULL AND `fin_documents`.`source_file_size_bytes` IS NULL AND `fin_documents`.`source_file_mime` IS NULL) OR (`fin_documents`.`source_file_bucket` IS NOT NULL AND `fin_documents`.`source_file_key` IS NOT NULL AND `fin_documents`.`source_file_hash_sha256` IS NOT NULL AND `fin_documents`.`source_file_size_bytes` IS NOT NULL AND `fin_documents`.`source_file_mime` IS NOT NULL)" + }, + "fin_documents_source_file_size_bytes_chk": { + "name": "fin_documents_source_file_size_bytes_chk", + "value": "`fin_documents`.`source_file_size_bytes` IS NULL OR `fin_documents`.`source_file_size_bytes` > 0" + } + } + }, + "fin_expected_counterpart": { + "name": "fin_expected_counterpart", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_account_ref": { + "name": "origin_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_reconciliation_ref": { + "name": "origin_reconciliation_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_transaction_ref": { + "name": "origin_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_date": { + "name": "expected_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matched_transaction_ref": { + "name": "matched_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_expected_counterpart_destination_status_idx": { + "name": "fin_expected_counterpart_destination_status_idx", + "columns": ["destination_account_ref", "status"], + "isUnique": false + }, + "fin_expected_counterpart_origin_reconciliation_idx": { + "name": "fin_expected_counterpart_origin_reconciliation_idx", + "columns": ["origin_reconciliation_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_expected_counterpart_id": { + "name": "fin_expected_counterpart_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_expected_counterpart_type_chk": { + "name": "fin_expected_counterpart_type_chk", + "value": "`fin_expected_counterpart`.`type` IN ('Transfer','Investment','Redemption')" + }, + "fin_expected_counterpart_movement_chk": { + "name": "fin_expected_counterpart_movement_chk", + "value": "`fin_expected_counterpart`.`movement` IN ('Debit','Credit')" + }, + "fin_expected_counterpart_status_chk": { + "name": "fin_expected_counterpart_status_chk", + "value": "`fin_expected_counterpart`.`status` IN ('Pending','Matched','Discarded')" + }, + "fin_expected_counterpart_value_chk": { + "name": "fin_expected_counterpart_value_chk", + "value": "`fin_expected_counterpart`.`value_cents` > 0" + } + } + }, + "fin_manual_entries": { + "name": "fin_manual_entries", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_type": { + "name": "document_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_value_cents": { + "name": "document_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_manual_entries_reconciliation_id_idx": { + "name": "fin_manual_entries_reconciliation_id_idx", + "columns": ["reconciliation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_manual_entries_reconciliation_id_fk": { + "name": "fin_manual_entries_reconciliation_id_fk", + "tableFrom": "fin_manual_entries", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_manual_entries_id": { + "name": "fin_manual_entries_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_manual_entries_type_chk": { + "name": "fin_manual_entries_type_chk", + "value": "`fin_manual_entries`.`type` IN ('Payment','Receipt','Transfer','FeePenaltyInterest','Investment','Redemption')" + }, + "fin_manual_entries_value_chk": { + "name": "fin_manual_entries_value_chk", + "value": "`fin_manual_entries`.`value_cents` > 0" + } + } + }, + "fin_outbox": { + "name": "fin_outbox", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_processed_at_occurred_at_idx": { + "name": "fin_outbox_processed_at_occurred_at_idx", + "columns": ["processed_at", "occurred_at"], + "isUnique": false + }, + "fin_outbox_aggregate_id_idx": { + "name": "fin_outbox_aggregate_id_idx", + "columns": ["aggregate_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_event_id": { + "name": "fin_outbox_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_attempts_nonneg_chk": { + "name": "fin_outbox_attempts_nonneg_chk", + "value": "`fin_outbox`.`attempts` >= 0" + }, + "fin_outbox_event_type_nonempty_chk": { + "name": "fin_outbox_event_type_nonempty_chk", + "value": "CHAR_LENGTH(`fin_outbox`.`event_type`) > 0" + }, + "fin_outbox_aggregate_type_chk": { + "name": "fin_outbox_aggregate_type_chk", + "value": "`fin_outbox`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_outbox_dead_letter": { + "name": "fin_outbox_dead_letter", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_dl_failed_at_idx": { + "name": "fin_outbox_dl_failed_at_idx", + "columns": ["failed_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_dead_letter_consumer_id_event_id_pk": { + "name": "fin_outbox_dead_letter_consumer_id_event_id_pk", + "columns": ["consumer_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_dl_attempts_nonneg_chk": { + "name": "fin_outbox_dl_attempts_nonneg_chk", + "value": "`fin_outbox_dead_letter`.`attempts` >= 0" + }, + "fin_outbox_dl_aggregate_type_chk": { + "name": "fin_outbox_dl_aggregate_type_chk", + "value": "`fin_outbox_dead_letter`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_payable_view": { + "name": "fin_payable_view", + "columns": { + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01 00:00:00.000'" + } + }, + "indexes": { + "fin_payable_view_status_idx": { + "name": "fin_payable_view_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_payable_view_cost_center_ref_idx": { + "name": "fin_payable_view_cost_center_ref_idx", + "columns": ["cost_center_ref"], + "isUnique": false + }, + "fin_payable_view_category_ref_idx": { + "name": "fin_payable_view_category_ref_idx", + "columns": ["category_ref"], + "isUnique": false + }, + "fin_payable_view_budget_plan_ref_idx": { + "name": "fin_payable_view_budget_plan_ref_idx", + "columns": ["budget_plan_ref"], + "isUnique": false + }, + "fin_payable_view_subcategory_ref_idx": { + "name": "fin_payable_view_subcategory_ref_idx", + "columns": ["subcategory_ref"], + "isUnique": false + }, + "fin_payable_view_program_ref_idx": { + "name": "fin_payable_view_program_ref_idx", + "columns": ["program_ref"], + "isUnique": false + }, + "fin_payable_view_supplier_ref_idx": { + "name": "fin_payable_view_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_payable_view_due_date_idx": { + "name": "fin_payable_view_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_payable_view_paid_at_idx": { + "name": "fin_payable_view_paid_at_idx", + "columns": ["paid_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_payable_view_payable_id": { + "name": "fin_payable_view_payable_id", + "columns": ["payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payable_view_kind_chk": { + "name": "fin_payable_view_kind_chk", + "value": "`fin_payable_view`.`kind` IN ('Parent','Child')" + }, + "fin_payable_view_status_chk": { + "name": "fin_payable_view_status_chk", + "value": "`fin_payable_view`.`status` IN ('Open','Approved','Transmitted','Paid','Cancelled')" + }, + "fin_payable_view_retention_type_chk": { + "name": "fin_payable_view_retention_type_chk", + "value": "`fin_payable_view`.`retention_type` IS NULL OR `fin_payable_view`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + } + } + }, + "fin_payables": { + "name": "fin_payables", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_payables_document_id_idx": { + "name": "fin_payables_document_id_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_payables_status_idx": { + "name": "fin_payables_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_payables_document_id_fk": { + "name": "fin_payables_document_id_fk", + "tableFrom": "fin_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_payables_id": { + "name": "fin_payables_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payables_kind_chk": { + "name": "fin_payables_kind_chk", + "value": "`fin_payables`.`kind` IN ('Parent','Child')" + }, + "fin_payables_retention_type_chk": { + "name": "fin_payables_retention_type_chk", + "value": "`fin_payables`.`retention_type` IS NULL OR `fin_payables`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_payables_status_chk": { + "name": "fin_payables_status_chk", + "value": "`fin_payables`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_payables_payment_method_chk": { + "name": "fin_payables_payment_method_chk", + "value": "`fin_payables`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_payables_value_chk": { + "name": "fin_payables_value_chk", + "value": "`fin_payables`.`value` >= 0" + }, + "fin_payables_child_retention_chk": { + "name": "fin_payables_child_retention_chk", + "value": "(`fin_payables`.`kind` = 'Child') = (`fin_payables`.`retention_type` IS NOT NULL)" + }, + "fin_payables_paid_at_chk": { + "name": "fin_payables_paid_at_chk", + "value": "`fin_payables`.`status` <> 'Paid' OR `fin_payables`.`paid_at` IS NOT NULL" + } + } + }, + "fin_reconciliation_items": { + "name": "fin_reconciliation_items", + "columns": { + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_value_cents": { + "name": "reconciled_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_items_payable_id_idx": { + "name": "fin_reconciliation_items_payable_id_idx", + "columns": ["payable_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_reconciliation_items_reconciliation_id_fk": { + "name": "fin_reconciliation_items_reconciliation_id_fk", + "tableFrom": "fin_reconciliation_items", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_reconciliation_items_reconciliation_id_payable_id_pk": { + "name": "fin_reconciliation_items_reconciliation_id_payable_id_pk", + "columns": ["reconciliation_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_reconciliation_periods": { + "name": "fin_reconciliation_periods", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_by": { + "name": "closed_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_periods_account_range_uq": { + "name": "fin_reconciliation_periods_account_range_uq", + "columns": ["debit_account_ref", "period_start", "period_end"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliation_periods_id": { + "name": "fin_reconciliation_periods_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliation_periods_status_chk": { + "name": "fin_reconciliation_periods_status_chk", + "value": "`fin_reconciliation_periods`.`status` IN ('Open','Closed')" + } + } + }, + "fin_reconciliations": { + "name": "fin_reconciliations", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difference_value_cents": { + "name": "difference_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "difference_treatment": { + "name": "difference_treatment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_by": { + "name": "reconciled_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undone_by": { + "name": "undone_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_reason": { + "name": "undo_reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliations_transaction_id_idx": { + "name": "fin_reconciliations_transaction_id_idx", + "columns": ["transaction_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliations_id": { + "name": "fin_reconciliations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliations_type_chk": { + "name": "fin_reconciliations_type_chk", + "value": "`fin_reconciliations`.`type` IN ('Individual','Multiple','Partial','ManualEntry')" + }, + "fin_reconciliations_status_chk": { + "name": "fin_reconciliations_status_chk", + "value": "`fin_reconciliations`.`status` IN ('Active','Undone')" + }, + "fin_reconciliations_difference_chk": { + "name": "fin_reconciliations_difference_chk", + "value": "(`fin_reconciliations`.`difference_value_cents` IS NULL AND `fin_reconciliations`.`difference_treatment` IS NULL) OR (`fin_reconciliations`.`difference_value_cents` IS NOT NULL AND `fin_reconciliations`.`difference_treatment` IN ('Interest','Penalty','Discount','Fee','Partial'))" + } + } + }, + "fin_registered_taxes": { + "name": "fin_registered_taxes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_registered_taxes_document_id_idx": { + "name": "fin_registered_taxes_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_registered_taxes_document_id_fk": { + "name": "fin_registered_taxes_document_id_fk", + "tableFrom": "fin_registered_taxes", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_registered_taxes_id": { + "name": "fin_registered_taxes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_registered_taxes_type_chk": { + "name": "fin_registered_taxes_type_chk", + "value": "`fin_registered_taxes`.`type` IN ('ICMS','IPI','PIS','COFINS','CBS','IBS_Municipal','IBS_Estadual')" + }, + "fin_registered_taxes_base_chk": { + "name": "fin_registered_taxes_base_chk", + "value": "`fin_registered_taxes`.`base` >= 0" + }, + "fin_registered_taxes_rate_bps_chk": { + "name": "fin_registered_taxes_rate_bps_chk", + "value": "`fin_registered_taxes`.`rate_bps` >= 0" + }, + "fin_registered_taxes_value_chk": { + "name": "fin_registered_taxes_value_chk", + "value": "`fin_registered_taxes`.`value` >= 0" + } + } + }, + "fin_rejected_suggestions": { + "name": "fin_rejected_suggestions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_rejected_suggestions_tx_payable_uq": { + "name": "fin_rejected_suggestions_tx_payable_uq", + "columns": ["transaction_id", "payable_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_rejected_suggestions_id": { + "name": "fin_rejected_suggestions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_documents": { + "name": "fin_remittance_documents", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_documents_document_idx": { + "name": "fin_remittance_documents_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_documents_your_number_uk": { + "name": "fin_remittance_documents_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittance_documents_remittance_id_document_id_pk": { + "name": "fin_remittance_documents_remittance_id_document_id_pk", + "columns": ["remittance_id", "document_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_payables": { + "name": "fin_remittance_payables", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_payables_payable_idx": { + "name": "fin_remittance_payables_payable_idx", + "columns": ["payable_id"], + "isUnique": false + }, + "fin_remittance_payables_document_idx": { + "name": "fin_remittance_payables_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_payables_your_number_uk": { + "name": "fin_remittance_payables_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": { + "fin_remittance_payables_remittance_id_fk": { + "name": "fin_remittance_payables_remittance_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_remittances", + "columnsFrom": ["remittance_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_payable_id_fk": { + "name": "fin_remittance_payables_payable_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_payables", + "columnsFrom": ["payable_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_document_id_fk": { + "name": "fin_remittance_payables_document_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_remittance_payables_remittance_id_payable_id_pk": { + "name": "fin_remittance_payables_remittance_id_payable_id_pk", + "columns": ["remittance_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittances": { + "name": "fin_remittances", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cedente_account_id": { + "name": "cedente_account_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nsa": { + "name": "nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_remittances_account_nsa_uq": { + "name": "fin_remittances_account_nsa_uq", + "columns": ["cedente_account_id", "nsa"], + "isUnique": true + }, + "fin_remittances_file_name_uq": { + "name": "fin_remittances_file_name_uq", + "columns": ["file_name"], + "isUnique": true + }, + "fin_remittances_status_idx": { + "name": "fin_remittances_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittances_id": { + "name": "fin_remittances_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_remittances_status_chk": { + "name": "fin_remittances_status_chk", + "value": "`fin_remittances`.`status` IN ('Queued','Transmitted','Failed','Discarded')" + }, + "fin_remittances_nsa_range_chk": { + "name": "fin_remittances_nsa_range_chk", + "value": "`fin_remittances`.`nsa` >= 1 AND `fin_remittances`.`nsa` <= 999999" + } + } + }, + "fin_retentions": { + "name": "fin_retentions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_retentions_document_id_idx": { + "name": "fin_retentions_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_retentions_document_id_fk": { + "name": "fin_retentions_document_id_fk", + "tableFrom": "fin_retentions", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_retentions_id": { + "name": "fin_retentions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_retentions_type_chk": { + "name": "fin_retentions_type_chk", + "value": "`fin_retentions`.`type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_retentions_base_chk": { + "name": "fin_retentions_base_chk", + "value": "`fin_retentions`.`base` >= 0" + }, + "fin_retentions_rate_bps_chk": { + "name": "fin_retentions_rate_bps_chk", + "value": "`fin_retentions`.`rate_bps` >= 0" + }, + "fin_retentions_value_chk": { + "name": "fin_retentions_value_chk", + "value": "`fin_retentions`.`value` >= 0" + } + } + }, + "fin_statement_transactions": { + "name": "fin_statement_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statement_id": { + "name": "statement_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fitid": { + "name": "fitid", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_type": { + "name": "entry_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payee_name": { + "name": "payee_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memo": { + "name": "memo", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balance_after_cents": { + "name": "balance_after_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_status": { + "name": "reconciliation_status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_statement_transactions_account_fitid_uq": { + "name": "fin_statement_transactions_account_fitid_uq", + "columns": ["debit_account_ref", "fitid"], + "isUnique": true + }, + "fin_statement_transactions_statement_id_idx": { + "name": "fin_statement_transactions_statement_id_idx", + "columns": ["statement_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_statement_transactions_statement_id_fk": { + "name": "fin_statement_transactions_statement_id_fk", + "tableFrom": "fin_statement_transactions", + "tableTo": "fin_bank_statements", + "columnsFrom": ["statement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_statement_transactions_id": { + "name": "fin_statement_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_statement_transactions_movement_chk": { + "name": "fin_statement_transactions_movement_chk", + "value": "`fin_statement_transactions`.`movement` IN ('Debit','Credit')" + }, + "fin_statement_transactions_entry_type_chk": { + "name": "fin_statement_transactions_entry_type_chk", + "value": "`fin_statement_transactions`.`entry_type` IN ('PIX','TED','DOC','Fee','Boleto','DARF','Investment','Redemption','Transfer','Other')" + }, + "fin_statement_transactions_recon_status_chk": { + "name": "fin_statement_transactions_recon_status_chk", + "value": "`fin_statement_transactions`.`reconciliation_status` IN ('Pending','Reconciled','ManualEntry')" + } + } + }, + "fin_supplier_view": { + "name": "fin_supplier_view", + "columns": { + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_supplier_view_supplier_ref": { + "name": "fin_supplier_view_supplier_ref", + "columns": ["supplier_ref"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_timeline_field_changes": { + "name": "fin_timeline_field_changes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timeline_entry_id": { + "name": "timeline_entry_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_value": { + "name": "before_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after_value": { + "name": "after_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tlfc_entry": { + "name": "idx_fin_tlfc_entry", + "columns": ["timeline_entry_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_timeline_field_changes_entry_id_fk": { + "name": "fin_timeline_field_changes_entry_id_fk", + "tableFrom": "fin_timeline_field_changes", + "tableTo": "fin_document_timeline", + "columnsFrom": ["timeline_entry_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_timeline_field_changes_id": { + "name": "fin_timeline_field_changes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_van_return_quarantine": { + "name": "fin_van_return_quarantine", + "columns": { + "object_key": { + "name": "object_key", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_sha256": { + "name": "observed_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_sha256": { + "name": "expected_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_van_return_quarantine_released_idx": { + "name": "fin_van_return_quarantine_released_idx", + "columns": ["released_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_van_return_quarantine_object_key": { + "name": "fin_van_return_quarantine_object_key", + "columns": ["object_key"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_van_return_quarantine_reason_chk": { + "name": "fin_van_return_quarantine_reason_chk", + "value": "`fin_van_return_quarantine`.`reason` IN ('missing-provenance','hash-mismatch','origin-not-logged')" + } + } + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json index 6388898d..3db70448 100644 --- a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json @@ -393,6 +393,13 @@ "when": 1788212989660, "tag": "0055_cuddly_legion", "breakpoints": true + }, + { + "idx": 56, + "version": "5", + "when": 1788702432348, + "tag": "0056_daily_firebrand", + "breakpoints": true } ] } diff --git a/src/modules/financial/adapters/persistence/repos/remittance-payment-reader.drizzle.ts b/src/modules/financial/adapters/persistence/repos/remittance-payment-reader.drizzle.ts index e379bced..1dc3bf69 100644 --- a/src/modules/financial/adapters/persistence/repos/remittance-payment-reader.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/remittance-payment-reader.drizzle.ts @@ -36,6 +36,14 @@ import { isApprovedForRemittance } from '#src/modules/financial/domain/document/ import { decomposePayeeAccount } from '#src/modules/financial/domain/payout/payee-account.ts'; import { resolveBarcode } from '#src/modules/financial/domain/payout/digitable-line.ts'; import { checkPayoutReadiness } from '#src/modules/financial/domain/payout/payout-readiness.ts'; +// ⚠️ DO DOMÍNIO, NÃO CÓPIAS LOCAIS (#856). As duas eram privadas deste arquivo enquanto só o +// favorecido as usava; o CEDENTE passou a precisar da mesma derivação, e duas cópias idênticas é a +// configuração que a #863 documenta como causa — duas decisões sobre o mesmo dado, nenhuma citando a +// outra, concordando por acidente até o dia em que uma mudasse. +import { + normalizeInscription, + inscriptionType, +} from '#src/modules/financial/domain/payout/inscription.ts'; import type { PayeeContractor } from '../../http/payee-bank-composition.ts'; import { finDocuments, finPayables } from '../schemas/mysql.ts'; @@ -47,15 +55,6 @@ export type PayeeContractorLookup = ( ref: Readonly<{ kind: PayeeKind | null; id: string | null }>, ) => Promise>; -// O tipo de inscrição sai do COMPRIMENTO: 11 posições é pessoa física, 14 é pessoa jurídica. -// -// A limpeza preserva letras de propósito — desde 07/2026 a inscrição de pessoa jurídica pode contê-las -// (ADR-0044). Filtrar só numerais encurtaria a string e faria uma inscrição válida ser classificada -// como física, gravando o tipo errado no arquivo. -const CPF_LENGTH = 11; -const cleanDocument = (raw: string): string => raw.replace(/[^0-9A-Za-z]/g, '').toUpperCase(); -const documentTypeOf = (raw: string): '1' | '2' => (raw.length === CPF_LENGTH ? '1' : '2'); - type PayableRow = Readonly<{ payableId: string; documentId: string; @@ -143,14 +142,14 @@ const toPaymentData = ( // preferível ao campo vazio no arquivo. if (!parts.ok || contractor === null) return err('remittance-payment-incomplete'); - const document = cleanDocument(contractor.document); + const document = normalizeInscription(contractor.document); return ok({ payableId: row.payableId, documentId: row.documentId, route: 'transfer', payee: { name: contractor.name, - documentType: documentTypeOf(document), + documentType: inscriptionType(document), document, bankCode: parts.value.bankCode, agency: parts.value.agency, @@ -188,7 +187,7 @@ const toPaymentData = ( // título sai da seleção ANTES de o NSA ser alocado, em vez de derrubar a remessa inteira // depois de queimar um número de sequência que não volta. if (contractor === null) return err('remittance-payment-incomplete'); - const beneficiaryDocument = cleanDocument(contractor.document); + const beneficiaryDocument = normalizeInscription(contractor.document); if (beneficiaryDocument === '') return err('remittance-payment-incomplete'); return ok({ @@ -198,7 +197,7 @@ const toPaymentData = ( barcode: barcode.value, // Nome e inscrição do CEDENTE do título: quem emitiu e recebe. beneficiaryName: contractor.name, - beneficiaryDocumentType: documentTypeOf(beneficiaryDocument), + beneficiaryDocumentType: inscriptionType(beneficiaryDocument), beneficiaryDocument, dueDate: paymentDate, valueCents, @@ -223,7 +222,7 @@ const toPaymentData = ( const pixKeyType = contractor.pixKey?.keyType ?? ''; if (pixKey === '' || pixKeyType === '') return err('remittance-payment-incomplete'); - const document = cleanDocument(contractor.document); + const document = normalizeInscription(contractor.document); if (document === '') return err('remittance-payment-incomplete'); return ok({ @@ -234,7 +233,7 @@ const toPaymentData = ( // bancário não tem para onde ir nesta rota. payee: { name: contractor.name, - documentType: documentTypeOf(document), + documentType: inscriptionType(document), document, }, pixKey, diff --git a/src/modules/financial/adapters/persistence/schemas/mysql.ts b/src/modules/financial/adapters/persistence/schemas/mysql.ts index 3187dacc..0e60dd62 100644 --- a/src/modules/financial/adapters/persistence/schemas/mysql.ts +++ b/src/modules/financial/adapters/persistence/schemas/mysql.ts @@ -649,6 +649,18 @@ export const finCedenteAccounts = mysqlTable( id: uuidKey('id').primaryKey().notNull(), bankCode: varchar('bank_code', { length: 8 }).notNull(), agency: varchar('agency', { length: 12 }).notNull(), + // DV da agência — posição 058 do header CNAB (#856). NULLABLE por duas razões independentes: + // a agência pode legitimamente não ter DV, e toda conta já cadastrada nasceu sem ele (não havia + // coluna, e o front descartava o dígito que o operador digitava). + // + // ⚠️ COLUNA PRÓPRIA, e não sufixo de `agency`. `agency` tem 12 posições e caberia `0288-2` — mas + // o emissor escreve `digits(agency, 5)`, que remove o separador e grava `02882` nas posições + // 053-057, onde o banco espera `00288`. Nenhum gate acusa. Ver `remittance-eligibility.ts`. + // + // ⚠️ FORA da UNIQUE de chave natural, de propósito: a identidade da conta é banco+agência+conta + // +dígito da CONTA (FR-016), e o DV da agência é atributo dela, não parte de quem ela é. Incluí-lo + // faria a mesma conta bancária caber duas vezes na tabela — uma com dígito, outra sem. + agencyDigit: varchar('agency_digit', { length: 2 }), accountNumber: varchar('account_number', { length: 20 }).notNull(), accountDigit: varchar('account_digit', { length: 4 }).notNull(), convenio: varchar('convenio', { length: 30 }).notNull(), diff --git a/src/modules/financial/application/use-cases/create-cedente-account.ts b/src/modules/financial/application/use-cases/create-cedente-account.ts index 6ed484f3..149b3632 100644 --- a/src/modules/financial/application/use-cases/create-cedente-account.ts +++ b/src/modules/financial/application/use-cases/create-cedente-account.ts @@ -17,6 +17,7 @@ export type CreateCedenteAccountInput = Readonly<{ type?: string; // raw — validado pelo domínio (invalid-account-type) typeLabel?: string; // #206: texto livre p/ identificar conta `outro`/`cartao` agency: string; + agencyDigit?: string; // #856 — posição 058 do header CNAB; a agência pode não ter DV. accountNumber: string; accountDigit: string; convenio?: string; @@ -57,6 +58,7 @@ export const createCedenteAccount = accountDigit: input.accountDigit, convenio: input.convenio ?? '', document: input.document, + ...(input.agencyDigit !== undefined ? { agencyDigit: input.agencyDigit } : {}), ...(input.type !== undefined ? { type: input.type as AccountType } : {}), ...(input.typeLabel !== undefined ? { typeLabel: input.typeLabel } : {}), ...(input.nickname !== undefined ? { nickname: input.nickname } : {}), diff --git a/src/modules/financial/application/use-cases/edit-cedente-account.ts b/src/modules/financial/application/use-cases/edit-cedente-account.ts index f49ff599..97fe5dbb 100644 --- a/src/modules/financial/application/use-cases/edit-cedente-account.ts +++ b/src/modules/financial/application/use-cases/edit-cedente-account.ts @@ -1,6 +1,6 @@ import { type Result, ok, err } from '../../../../shared/primitives/result.ts'; import * as CedenteAccountId from '../../domain/cedente/cedente-account-id.ts'; -import { checkCedenteRemittanceReadiness } from '../../domain/cedente/remittance-eligibility.ts'; +import { checkCedenteConvenio } from '../../domain/cedente/remittance-eligibility.ts'; import type { AccountType, CedenteAccount } from '../../domain/cedente/types.ts'; import type { CedenteAccountIdError } from '../../domain/cedente/cedente-account-id.ts'; import type { @@ -17,6 +17,13 @@ export type EditCedenteAccountInput = Readonly<{ // Dados bancários (travados após histórico — FR-008). bankCode?: string; agency?: string; + // Preenchível quando ausente, pelo MESMO desenho do convênio abaixo (#856). A conta migrada nasceu + // sem DV porque não havia coluna, e ela tem histórico — sob a trava FR-008 pura, nenhuma conta em + // uso poderia ganhar o dígito, que é precisamente o beco medido em produção (#942/#943): o + // operador digita `0288-2`, o dígito é descartado, e ao reabrir a tela o campo volta vermelho e + // trava o Salvar de TODOS os outros campos. Preencher o vazio destrava; TROCAR um DV já definido + // continua sendo alteração de dado bancário e cai na trava. + agencyDigit?: string; accountNumber?: string; accountDigit?: string; type?: string; @@ -55,12 +62,28 @@ export const editCedenteAccount = if (!found.ok) return found; if (found.value === null) return err('cedente-account-not-found'); + // `''` colapsa em ausente pela mesma razão que no construtor: é o que o front envia quando o + // operador não digitou DV, e tratá-lo como valor faria "sem dígito" parecer "dígito definido". + const agencyDigitPatch = + input.agencyDigit === undefined || input.agencyDigit.trim() === '' + ? undefined + : input.agencyDigit.trim(); + + // PREENCHER o que está vazio não é alterar dado bancário — é completar um cadastro que nasceu + // incompleto por ausência de coluna. TROCAR um dígito já definido é, e por isso só o segundo caso + // entra na trava FR-008. + const wantsAgencyDigitSwap = + agencyDigitPatch !== undefined && + found.value.agencyDigit !== undefined && + found.value.agencyDigit !== agencyDigitPatch; + const wantsBankDataChange = input.bankCode !== undefined || input.agency !== undefined || input.accountNumber !== undefined || input.accountDigit !== undefined || - input.type !== undefined; + input.type !== undefined || + wantsAgencyDigitSwap; if (wantsBankDataChange) { const hist = await deps.accountHistory.hasActivity(id.value); @@ -79,17 +102,22 @@ export const editCedenteAccount = // gerar remessa, e nenhuma tinha conserto — a porta que a #722 abriu foi trancada por um valor // que só *parecia* dado. // - // A régua de "serve como convênio?" NÃO é escrita aqui: é `checkCedenteRemittanceReadiness`, no - // domínio, e é a MESMA que decide se a conta gera remessa. Uma segunda régua para o mesmo fato - // divergiria — e a divergência entre duas réguas de elegibilidade é exatamente o defeito que a - // #837 fechou do outro lado do módulo. Aqui só se pergunta a ela. + // A régua de "serve como convênio?" NÃO é escrita aqui: é `checkCedenteConvenio`, no domínio, e é + // a MESMA que decide se a conta gera remessa. Uma segunda régua para o mesmo fato divergiria — e + // a divergência entre duas réguas de elegibilidade é exatamente o defeito que a #837 fechou do + // outro lado do módulo. Aqui só se pergunta a ela. + // + // ⚠️ A pergunta é `checkCedenteConvenio`, e NÃO a readiness inteira (#856). Desde que a agência + // entrou na readiness, chamá-la aqui responderia "não serve" para uma conta cujo convênio está + // perfeito e cuja AGÊNCIA está malformada — destravando a troca que o #722 fechou, por um + // defeito que nada tem a ver com o convênio. // // O efeito é o correto nos dois sentidos: convênio malformado ou longo demais passa a ACEITAR // correção pela tela (é o que o operador precisa), e convênio válido continua RECUSANDO a troca // — o invariante do #722 não afrouxa. const wantsConvenioChange = input.convenio !== undefined && input.convenio.trim() !== found.value.convenio.trim(); - const currentConvenioIsUsable = checkCedenteRemittanceReadiness(found.value).ok; + const currentConvenioIsUsable = checkCedenteConvenio(found.value).ok; if (wantsConvenioChange && currentConvenioIsUsable) { return err('cedente-convenio-already-set'); } @@ -98,6 +126,7 @@ export const editCedenteAccount = ...found.value, ...(input.bankCode !== undefined ? { bankCode: input.bankCode } : {}), ...(input.agency !== undefined ? { agency: input.agency } : {}), + ...(agencyDigitPatch !== undefined ? { agencyDigit: agencyDigitPatch } : {}), ...(input.accountNumber !== undefined ? { accountNumber: input.accountNumber } : {}), ...(input.accountDigit !== undefined ? { accountDigit: input.accountDigit } : {}), ...(input.convenio !== undefined ? { convenio: input.convenio.trim() } : {}), diff --git a/src/modules/financial/application/use-cases/generate-remittance.ts b/src/modules/financial/application/use-cases/generate-remittance.ts index 3226de62..eedb63fa 100644 --- a/src/modules/financial/application/use-cases/generate-remittance.ts +++ b/src/modules/financial/application/use-cases/generate-remittance.ts @@ -7,6 +7,7 @@ import { checkCedenteRemittanceReadiness, type CedenteRemittanceGap, } from '../../domain/cedente/remittance-eligibility.ts'; +import { inscriptionType } from '../../domain/payout/inscription.ts'; import type { Remittance } from '../../domain/remittance/types.ts'; import type { RemittancePaymentData } from '../ports/remittance-payment-reader.ts'; import type { CedenteAccountStore } from '../ports/cedente-account-store.ts'; @@ -169,19 +170,47 @@ export const generateRemittance = // cedentes diferentes, que é o tipo de divergência que nenhum teste procura. const cedente = { bankCode: account.value.bankCode, - documentType: '2', + // 018 — G005. DERIVADO da inscrição, não afirmado (#856, CA4). Era `'2'` literal: todo cedente + // saía declarado pessoa jurídica, e um cedente pessoa física produzia arquivo bem-formado cujo + // tipo de inscrição não corresponde ao titular. A régua é a MESMA que o reader usa para o + // favorecido — uma função só, no domínio, medindo o comprimento da inscrição normalizada. + documentType: inscriptionType(account.value.document), document: account.value.document, convenio: account.value.convenio, agency: account.value.agency, - agencyDigit: '', + // 058 — G009. Sai do CADASTRO desde a #856; era `''` literal, e o dígito que o operador digita + // na tela desde 25/08 (specs/107 do web-app) não tinha onde ser gravado. + // + // Ausente continua sendo BRANCO, e isso é o layout, não desistência: `Alfa` vazio é brancos + // (p. 14), e a agência pode legitimamente não ter DV. O que mudou é que o branco passou a + // significar "esta agência não tem dígito" em vez de "o sistema não sabe". + // + // ⚠️ Nunca `'0'` por omissão. `05-armadilhas-e-divergencias.md` §2 é explícito: "se o DV for + // `0`, enviar `0`; se a agência realmente não tiver DV, enviar branco. Nunca zero por padrão + // sem confirmar" — zero é um dígito afirmado, e afirmar o errado é pior que não afirmar. + agencyDigit: account.value.agencyDigit ?? '', accountNumber: account.value.accountNumber, accountDigit: account.value.accountDigit, + // 072 — G012. BRANCO, e a ausência é justificada, não esquecida (#856, CA2 · ramo facultativo). + // + // O campo não é "o segundo DV do cedente": `G012` (layout v08, p. 96) o define como a **2ª + // posição do DV** para bancos cujo dígito de conta tem duas posições — o exemplo do próprio + // manual é `45981-36`, com `3` na 071 e `6` na 072. O DV de conta do Bradesco tem UMA posição: + // `bradescoAccountCheckDigits` (Manual de Procedimentos 4008-523-0096 v16, p. 30) devolve um + // único caractere, `0`–`9` ou `P`. Não existe segunda posição a gravar. + // + // Confirmado do outro lado, no arquivo que o banco aceitou: a inquiry-0033 mediu 18 submissões + // ao Validador Universal em 25/08/2026, com os DVs de agência/conta vazios em três cenários e + // **nenhuma crítica** a eles. + // + // ⚠️ Por isso NÃO ganhou coluna, ao contrário da 058: uma coluna aqui pediria ao operador um + // dígito que a conta dele não tem, e o que ele digitasse iria para o arquivo. accountAgencyDigit: '', companyName: CEDENTE_COMPANY_NAME, - // ⚠️ Continua saindo com 30 brancos sempre que a coluna é NULL — o caso de TODA conta vinda - // do ETL, que nunca preenche o campo. Fica assim de propósito: o destinatário do arquivo é o - // próprio banco, e afirmar que o branco é inofensivo exige o layout, não dedução. Registrado - // na #856 junto com os DVs de 058/072 e o tipo de inscrição. + // ⚠️ Sai com 30 brancos quando a coluna é NULL — o caso de TODA conta vinda do ETL, que nunca + // preenche o campo. Fica assim de propósito: 103-132 é o nome do BANCO, o destinatário do + // arquivo é o próprio banco, e o layout (p. 15, G014) não marca o campo como obrigatório — + // é uma das duas colunas sem asterisco do header, ao lado do nome da empresa. bankName: account.value.bankName ?? '', } as const; diff --git a/src/modules/financial/domain/cedente/cedente-account.ts b/src/modules/financial/domain/cedente/cedente-account.ts index 5df3bd50..40e6e215 100644 --- a/src/modules/financial/domain/cedente/cedente-account.ts +++ b/src/modules/financial/domain/cedente/cedente-account.ts @@ -41,6 +41,13 @@ export const create = (input: CreateInput): Result, -): Result => { +): Result => { // Espaço em volta não é erro do operador: o cadastro aceita e o dado continua legível. const convenio = account.convenio.trim(); @@ -54,3 +81,68 @@ export const checkCedenteRemittanceReadiness = ( return ok(undefined); }; + +// As posições 053-057 do header, e são SÓ a agência — o DV mora na 058, em campo próprio. +// +// ⚠️ ESTE É O CAMPO QUE FALHA SEM FAZER BARULHO, e por isso a recusa existe (#856, herdada da #859). +// O emissor escreve `digits(agency, 5)`, e `digits()` faz `replace(/\D/g,'')` ANTES do pad: +// +// digits('1487-2', 5) → num('14872', 5) → '14872' +// +// O banco espera `01487` ali. O resultado tem cinco dígitos, cabe no campo, não estoura o +// `numeric-field-overflow`, atravessa o `remittance-inspector` — que valida forma, e a forma está +// perfeita — e vai ao banco em TODA remessa daquela conta apontando outra agência. É a mesma classe +// do convênio truncado da #804: o banco não recusa, processa sob identidade errada. +// +// Recusar é a única saída que não inventa dado. Separar o DV por conta própria seria adivinhar qual +// metade é a agência (`12345` é `1234`+`5` ou `12345` sem DV?), e a #708 já estabeleceu que essa +// ambiguidade não se resolve por palpite — resolve-se com campo próprio, que é o `agencyDigit`. +const AGENCY_WIDTH = 5; + +export const checkCedenteAgency = ( + account: Readonly<{ agency: string }>, +): Result => { + const agency = account.agency.trim(); + + if (agency === '') return err('cedente-agency-missing'); + // Não-numérico é, na prática, agência com separador — o caso que o front descreve na #859, em que + // o operador digita `0288-2` e a base e o dígito acabam no mesmo campo. + if (!NUMERIC_ONLY.test(agency)) return err('cedente-agency-malformed'); + // Estouro tem o mesmo desfecho de propósito: `num()` já o recusaria no montador, mas lá o NSA já + // foi queimado. Aqui é antes, e a ação do operador — conferir a agência no cadastro — é a mesma. + if (agency.length > AGENCY_WIDTH) return err('cedente-agency-malformed'); + + return ok(undefined); +}; + +// A inscrição do cedente — 019-032, `G006` (#856, CA3). +// +// ⚠️ A RECUSA VIVE AQUI, e não só no emissor, pela ordem: o emissor monta DEPOIS do `allocateNsa`, e +// o número não volta. Sem esta barreira, um cedente de CNPJ alfanumérico queimaria um da sequência a +// cada tentativa — e chegaria ao operador como falha de montagem, sem apontar campo nenhum. +export const checkCedenteInscription = ( + account: Readonly<{ document: string }>, +): Result => { + // ⚠️ A pergunta é `hasInscription`, não `trim() === ''`, e a diferença não é preciosismo: `'---'`, + // `'.'` e `'./-'` sobrevivem ao `trim()` e normalizam para vazio. Perguntando pelo trim, eles + // seriam vistos como inscrição PRESENTE e — não sendo numéricos — classificados como + // "alfanumérica", mandando escalar ao banco um cadastro que só está incompleto. + if (normalizeInscription(account.document) === '') return err('cedente-inscription-missing'); + if (!isCnabEmittableInscription(account.document)) { + return err('cedente-inscription-alphanumeric'); + } + + return ok(undefined); +}; + +export const checkCedenteRemittanceReadiness = ( + account: Readonly<{ convenio: string; agency: string; document: string }>, +): Result => { + const convenio = checkCedenteConvenio(account); + if (!convenio.ok) return convenio; + + const agency = checkCedenteAgency(account); + if (!agency.ok) return agency; + + return checkCedenteInscription(account); +}; diff --git a/src/modules/financial/domain/cedente/types.ts b/src/modules/financial/domain/cedente/types.ts index a4cc3fdd..da6fe82d 100644 --- a/src/modules/financial/domain/cedente/types.ts +++ b/src/modules/financial/domain/cedente/types.ts @@ -22,6 +22,17 @@ export type CedenteAccount = Readonly<{ id: CedenteAccountId; bankCode: string; agency: string; + // DV DA AGÊNCIA — posição 058 do header de arquivo e de lote (#856, G009). + // + // ⚠️ CAMPO PRÓPRIO, e NUNCA concatenado em `agency`. O emissor escreve `digits(agency, 5)`, que faz + // `replace(/\D/g,'')` antes do pad: guardar `1487-2` em `agency` produziria `14872` nas posições + // 053-057, onde o banco espera `01487` — cinco dígitos, cabe no campo, nenhum gate acusa, e toda + // remessa daquela conta sai com a agência errada. É a armadilha que o front registrou na #859 e a + // razão de `checkCedenteRemittanceReadiness` RECUSAR agência com separador em vez de truncá-la. + // + // OPCIONAL porque o cadastro legado não o tem (não havia coluna) e porque a agência pode não ter + // DV. Ausente, a 058 sai em branco — `Alfa` vazio é brancos, nunca zeros (layout p. 14). + agencyDigit?: string; accountNumber: string; accountDigit: string; convenio: string; @@ -42,6 +53,7 @@ export type CreateInput = Readonly<{ id: CedenteAccountId; bankCode: string; agency: string; + agencyDigit?: string; accountNumber: string; accountDigit: string; convenio: string; diff --git a/src/modules/financial/domain/payout/inscription.ts b/src/modules/financial/domain/payout/inscription.ts new file mode 100644 index 00000000..f7648d2f --- /dev/null +++ b/src/modules/financial/domain/payout/inscription.ts @@ -0,0 +1,84 @@ +// A INSCRIÇÃO (CPF/CNPJ) como o arquivo do banco precisa dela — fonte ÚNICA, no molde de +// `van-routes.ts` (#837) e de `pix-key.ts` (#948). +// +// ## Por que a regra vive no DOMÍNIO +// +// Mesma seta de dependência de sempre: quem precisa dela é o emissor E o reader, em camadas +// diferentes, e adapter não alcança adapter. Com a regra no domínio, os dois descem até ela. +// +// ## O CNPJ alfanumérico não é hipótese +// +// A Receita emite CNPJ com letras desde 07/2026 (ADR-0044): doze posições alfanuméricas mais dois +// dígitos verificadores numéricos. É por isso que a normalização preserva letras — e a preservação +// não é cosmética: ela é o que mantém o COMPRIMENTO fiel, e é do comprimento que sai o tipo. + +/** + * A inscrição sem máscara, preservando letras. + * + * ⚠️ ERA UM `cleanDocument` PRIVADO do reader (`remittance-payment-reader.drizzle.ts`), e subiu aqui + * na #856 porque passou a ter um segundo chamador: o cedente, cujo tipo de inscrição também sai do + * comprimento. Duas cópias idênticas caractere a caractere é a configuração que a #863 documenta como + * causa — elas concordam por acidente até o dia em que uma muda. + * + * Maiúsculas porque o CNPJ alfanumérico da RFB é definido em maiúsculas — `12abc3` e `12ABC3` são a + * mesma inscrição, e deixar as duas formas circularem produziria comparações que falham por caixa. + */ +export const normalizeInscription = (raw: string): string => + raw.replace(/[^0-9A-Za-z]/g, '').toUpperCase(); + +const DIGITS_ONLY = /^\d+$/; + +/** + * "Esta inscrição pode ser escrita num campo `Num` do CNAB sem virar OUTRA?" + * + * Só é `true` para inscrição não-vazia e inteiramente numérica depois de tirada a máscara. + * + * ⚠️ O QUE ACONTECE COM QUEM NÃO PERGUNTA (#856, CA3): o emissor escreve a inscrição com um helper + * que remove tudo que não é dígito. Isso é tradução de formato legítima para máscara — `12.345.678/ + * 0001-99` e `12345678000199` são o mesmo CNPJ. Aplicado a um documento ALFANUMÉRICO, deixa de tirar + * pontuação e passa a DESTRUIR CONTEÚDO: + * + * '12ABC34501DE35' → '00000123450135' ← outra inscrição, 14 dígitos, arquivo perfeito + * + * O banco aceita. O cedente declarado no arquivo não é o titular da conta que paga. + * + * Documento alfanumérico responde `false`, e a recusa é DELIBERADA — não uma limitação a contornar: + * + * · **emitir com as letras** num campo declarado `Num` produz arquivo que o banco pode recusar, e + * a recusa chegaria depois do NSA queimado; + * · **remover as letras** é o defeito que esta função existe para impedir; + * · **converter** (o `ASCII − 48` da RFB) NÃO é opção que se escolha sozinho: aquela regra é para + * CALCULAR o dígito verificador, não para transmitir a inscrição. + * + * ⚠️ HÁ PERGUNTA EM ABERTO AO BANCO (#863): o Bradesco aceita CNPJ alfanumérico no CNAB 240, e em + * que forma? O layout v08 (jul/2025) é anterior à emissão alfanumérica (07/2026, ADR-0044) e declara + * o campo `Num`. Enquanto não houver resposta ESCRITA, recusar com nome próprio é a única saída que + * não inventa layout nem paga errado. + */ +export const isCnabEmittableInscription = (raw: string): boolean => { + const normalized = normalizeInscription(raw); + return normalized !== '' && DIGITS_ONLY.test(normalized); +}; + +const CPF_LENGTH = 11; + +/** + * O TIPO DE INSCRIÇÃO como o campo `G005` o quer: `1` pessoa física, `2` pessoa jurídica. + * + * ⚠️ SUBSTITUI O `'2'` LITERAL do cedente (#856, CA4). O emissor afirmava pessoa jurídica para toda + * conta-cedente; o reader, três camadas abaixo, MEDIA o mesmo fato para o favorecido. Duas decisões + * sobre a mesma grandeza, uma medindo e outra afirmando — e a que afirmava declara pessoa jurídica um + * cedente pessoa física, produzindo arquivo bem-formado cujo `G005` mente sobre quem paga. O banco + * não recusa: o tipo é coerente com o campo, só não é coerente com o titular. + * + * O comprimento é medido sobre a inscrição NORMALIZADA, e essa dependência é real e vale declará-la: + * filtrar só numerais encurtaria um CNPJ alfanumérico (ADR-0044), e 11 posições sobreviventes o + * classificariam como pessoa física. Qualquer mudança em `normalizeInscription` reclassifica pessoa + * física e jurídica nos dois lados do arquivo — com uma fonte só, essa mudança é visível de um lado. + * + * ⚠️ NÃO decide se a inscrição é EMISSÍVEL, e as duas perguntas são independentes: um CNPJ + * alfanumérico tem tipo `2` e ainda assim não cabe num campo declarado `Num` pelo layout. Essa + * segunda pergunta é a da #863, e a resposta dela mora ao lado desta função — não dentro. + */ +export const inscriptionType = (raw: string): '1' | '2' => + normalizeInscription(raw).length === CPF_LENGTH ? '1' : '2'; diff --git a/src/modules/financial/domain/payout/payee-account.ts b/src/modules/financial/domain/payout/payee-account.ts index 2b39f364..8a19b500 100644 --- a/src/modules/financial/domain/payout/payee-account.ts +++ b/src/modules/financial/domain/payout/payee-account.ts @@ -73,7 +73,7 @@ const trimmed = (value: string | null | undefined): string => value?.trim() ?? ' // O que sobra ao separar `1234-5`: a base e o dígito, quando há um. `digit: null` significa "não // veio", não "é vazio" — a diferença decide se o campo posicional sai em branco ou se o cadastro // está malformado. -type CheckDigitSplit = Readonly<{ base: string; digit: string | null }>; +export type CheckDigitSplit = Readonly<{ base: string; digit: string | null }>; // Separa `1234-5` em base + DV. SEM separador não decompõe: `12345` pode ser agência de cinco // dígitos ou quatro mais DV, e a escolha depende do banco. Devolver `digit: null` empurra a decisão @@ -81,7 +81,13 @@ type CheckDigitSplit = Readonly<{ base: string; digit: string | null }>; // // Quando o DV tem duas posições, só a PRIMEIRA vai para o campo (regra G011 citada acima). O // descarte da segunda é do layout, não nosso: o campo tem uma posição só. -const splitCheckDigit = (raw: string): CheckDigitSplit | null => { +// +// ⚠️ EXPORTADA na #856, e o segundo chamador é o ETL. A agência legada vem com o DV embutido +// (`0288-7`, evidência F7) e era gravada INTEIRA na coluna `agency` — de onde `digits(agency, 5)` +// removia o separador e escrevia `02887` nas posições 053-057, onde o banco espera `00288`. A mesma +// gramática que decompõe a conta do favorecido decompõe a agência do cedente; escrever uma segunda +// no `scripts/` faria as duas divergirem no dia em que uma mudasse. +export const splitCheckDigit = (raw: string): CheckDigitSplit | null => { const withDigit = WITH_CHECK_DIGIT_RE.exec(raw); if (withDigit !== null) { const [, base, digit] = withDigit; diff --git a/tests/etl/financial/mapper.test.ts b/tests/etl/financial/mapper.test.ts index 46c1477e..ba0ab592 100644 --- a/tests/etl/financial/mapper.test.ts +++ b/tests/etl/financial/mapper.test.ts @@ -67,7 +67,10 @@ describe('mapLegacyAccountRow — accounts → plano de conta-cedente', () => { id: 1, name: 'PARC', bank: 'BRADESCO', - agency: '0288-7', + // ⚠️ Agência SINTÉTICA. O valor anterior era um número de agência plausivelmente copiado do + // cadastro real, e estes repositórios são públicos (CLAUDE.md, anti-padrão 9). A FORMA é o que + // o caso mede — `NNNN-D`, o formato F7 do legado —, e ela se preserva com qualquer número. + agency: '1234-5', accountNumber: '12345', dv: '0', initialBalance: 1000.5, @@ -76,12 +79,13 @@ describe('mapLegacyAccountRow — accounts → plano de conta-cedente', () => { ...over, }); - it('mapeia Bradesco → bankCode 237, convenio VAZIO (D6), agência preservada (F7)', () => { + it('mapeia Bradesco → bankCode 237, convenio VAZIO (D6), agência DECOMPOSTA (F7 · #856)', () => { const r = mapLegacyAccountRow(account()); assert.ok(r.ok); assert.equal(r.value.legacyId, 1); assert.equal(r.value.input.bankCode, '237'); - assert.equal(r.value.input.agency, '0288-7'); + assert.equal(r.value.input.agency, '1234'); + assert.equal(r.value.input.agencyDigit, '5'); assert.equal(r.value.input.accountNumber, '12345'); assert.equal(r.value.input.accountDigit, '0'); assert.equal(r.value.input.convenio, ''); @@ -117,6 +121,42 @@ describe('mapLegacyAccountRow — accounts → plano de conta-cedente', () => { assert.ok(!r.ok); assert.ok(r.error.some((e) => e.tag === 'EnumUnknown' && e.field === 'bank')); }); + + /* + * ⚠️ O CASO QUE MOTIVOU A DECOMPOSIÇÃO (#856), e o assert é sobre o que NÃO acontece mais. + * + * A agência ia INTEIRA para a coluna, com o separador. O emissor escreve `digits(agency, 5)`, que + * remove o `-` antes do pad: `1234-5` virava `12345` nas posições 053-057, onde o banco espera + * `01234`. Cinco dígitos, cabe no campo, o `remittance-inspector` aprova — ele valida forma, e a + * forma fica perfeita — e o arquivo vai ao banco apontando outra agência, em toda remessa daquela + * conta. O comentário do mapper afirmava que o dígito ia "no campo próprio"; não ia, porque campo + * próprio não existia. + */ + it('#856: a agência migrada NÃO retém o separador — ele corromperia as posições 053-057', () => { + const r = mapLegacyAccountRow(account()); + assert.ok(r.ok); + assert.ok( + !/\D/.test(r.value.input.agency), + 'agência migrada com separador vira outra agência no arquivo', + ); + }); + + it('#856: agência sem DV no legado migra sem dígito — nunca com um zero inventado', () => { + const r = mapLegacyAccountRow(account({ agency: '1234' })); + assert.ok(r.ok); + assert.equal(r.value.input.agency, '1234'); + assert.equal(r.value.input.agencyDigit, undefined); + }); + + // Não decompõe → não migra errado, e também NÃO vai para quarentena: a conta serve à conciliação + // sem agência válida, e barrar a migração repetiria o beco do `'LEGADO'` (#879) — conta ausente do + // sistema, sem via de correção pela tela. Quem recusa é `checkCedenteAgency`, na geração. + it('#856: agência que não decompõe migra como veio, para a régua da remessa recusar depois', () => { + const r = mapLegacyAccountRow(account({ agency: 'AG 12' })); + assert.ok(r.ok); + assert.equal(r.value.input.agency, 'AG 12'); + assert.equal(r.value.input.agencyDigit, undefined); + }); }); describe('mapLegacyPayableRow — vocabulário (D7) e refs', () => { diff --git a/tests/modules/financial/adapters/http/error-classification.test.ts b/tests/modules/financial/adapters/http/error-classification.test.ts index 39d46f6a..6e2b5822 100644 --- a/tests/modules/financial/adapters/http/error-classification.test.ts +++ b/tests/modules/financial/adapters/http/error-classification.test.ts @@ -78,6 +78,45 @@ describe('error-mapping — mensagem PT-BR nunca vaza o slug (#52)', () => { } }); +/* + * As lacunas da conta-cedente (#856) — e por que elas precisam de bloco próprio. + * + * ⚠️ O mapa de mensagens é `ReadonlySet` + objeto, NÃO união exaustiva: o compilador não cobra a + * linha, e um slug sem entrada sai no fallback genérico. Quem gerar remessa com a agência + * malformada leria "erro interno" para um dado que ele mesmo corrige na tela — que é exatamente o + * defeito de UX que a #722 fechou para o convênio e que estes slugs poderiam reintroduzir. + * + * O bloco também vigia a DISTINÇÃO: os dois da inscrição terminam em ações opostas — o ausente se + * resolve no cadastro, o alfanumérico não se resolve em lugar nenhum do ERP e manda escalar ao + * banco. Uma mensagem só serviria mal às duas. + */ +describe('error-mapping — as lacunas da conta-cedente têm mensagem própria (#856)', () => { + const slugs = [ + 'cedente-agency-missing', + 'cedente-agency-malformed', + 'cedente-inscription-missing', + 'cedente-inscription-alphanumeric', + ]; + + for (const slug of slugs) { + it(`${slug} tem mensagem PT-BR ≠ slug`, () => { + const msg = toPublicMessage(slug); + assert.ok(msg.length > 0, 'mensagem não pode ser vazia'); + assert.notEqual(msg, slug, 'mensagem não pode ser o slug interno'); + assert.notEqual( + msg, + toPublicMessage('slug-que-nao-existe-no-mapa'), + 'slug sem entrada cai no fallback genérico e não diz o que corrigir', + ); + }); + } + + it('as quatro mensagens são DISTINTAS entre si', () => { + const messages = new Set(slugs.map((s) => toPublicMessage(s))); + assert.equal(messages.size, slugs.length); + }); +}); + // Os dois conflitos de CAS por título nasceram como UM slug só, e a mensagem — escrita para a baixa // — chegava a quem tinha tentado reagendar. O gate não pegou: os testes da borda asseram // `error.code`, e é a decisão certa (acoplar teste a string de UX é frágil). O efeito colateral é diff --git a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts index bc0d04b3..c4ea1679 100644 --- a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts +++ b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts @@ -124,6 +124,70 @@ describe('financial/http — cedente-accounts (019) — W0 RED', () => { assert.equal(item.currentBalanceCents, '50000'); }); + /* + * O CONTRATO DO DV DA AGÊNCIA (#856), na forma que a #859 pediu: opcional no create e no edit, + * PRESENTE na leitura. + * + * ⚠️ A leitura é a metade que resolve o beco medido em produção (#942/#943). Sem `agencyDigit` na + * resposta, o front reabre a edição com quatro dígitos, marca a agência como incompleta e + * desabilita o Salvar — então alterar QUALQUER outro campo da conta exige redigitar um dígito que + * seria descartado de novo. Um teste que só cobrisse o POST ficaria verde com o beco intacto. + */ + it('#856: o DV da agência entra no POST e volta na leitura', async () => { + const created = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ accountNumber: '445566', agencyDigit: '5', nickname: 'Conta com DV' }), + }); + assert.equal(created.statusCode, 201, created.body); + assert.equal((created.json() as { agencyDigit: string | null }).agencyDigit, '5'); + + const id = (created.json() as { id: string }).id; + const read = await handle.app.inject({ + method: 'GET', + url: `/api/v2/financial/cedente-accounts/${id}`, + headers: { authorization: `Bearer ${READER}` }, + }); + assert.equal(read.statusCode, 200, read.body); + assert.equal((read.json() as { agencyDigit: string | null }).agencyDigit, '5'); + }); + + // Opcional de verdade: a conta cadastrada sem DV continua sendo criada, e a leitura diz `null` em + // vez de omitir o campo — o front precisa distinguir "não tem" de "não veio no contrato". + it('#856: conta sem DV é criada, e a leitura devolve null', async () => { + const created = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ accountNumber: '556677', nickname: 'Conta sem DV' }), + }); + assert.equal(created.statusCode, 201, created.body); + assert.equal((created.json() as { agencyDigit: string | null }).agencyDigit, null); + }); + + // O PATCH é o caminho pelo qual a conta JÁ CADASTRADA se completa. Sem ele, a coluna nova só + // serviria a contas criadas do zero — e as que estão em uso ficariam no beco para sempre. + it('#856: o PATCH preenche o DV de uma conta que nasceu sem ele', async () => { + const created = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ accountNumber: '667788', nickname: 'Conta a completar' }), + }); + assert.equal(created.statusCode, 201, created.body); + const id = (created.json() as { id: string }).id; + + const patched = await handle.app.inject({ + method: 'PATCH', + url: `/api/v2/financial/cedente-accounts/${id}`, + headers: { authorization: `Bearer ${WRITER}` }, + payload: { agencyDigit: '7' }, + }); + assert.equal(patched.statusCode, 200, patched.body); + assert.equal((patched.json() as { agencyDigit: string | null }).agencyDigit, '7'); + }); + it('CA-US2: POST /cedente-accounts/:id/close → rota existe (≠ 404)', async () => { const id = '11111111-1111-4111-8111-111111111111'; const res = await handle.app.inject({ diff --git a/tests/modules/financial/adapters/http/strict-response-schemas.test.ts b/tests/modules/financial/adapters/http/strict-response-schemas.test.ts index 487cb405..0d6af7bf 100644 --- a/tests/modules/financial/adapters/http/strict-response-schemas.test.ts +++ b/tests/modules/financial/adapters/http/strict-response-schemas.test.ts @@ -205,6 +205,9 @@ describe('cedenteAccountResponseSchema — .strict() (#384)', () => { type: 'corrente', typeLabel: null, agency: '0001', + // #856 — presente na leitura para o front exibir o cadastro completo na edição. `null` é o + // estado de toda conta migrada: nasceu sem DV porque não havia coluna. + agencyDigit: null, accountNumber: '12345', accountDigit: '6', convenio: '', diff --git a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts index 8e17d55b..5b662f20 100644 --- a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts +++ b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts @@ -38,6 +38,8 @@ const rowOf = ( document: account.document, status: account.status as string, nextNsa: account.nextNsa, + // #856 — DV da agência (posição 058); nullable, e a conta migrada nasce sem ele. + agencyDigit: account.agencyDigit ?? null, // Extensão conciliação (019) — colunas nullable; default null no fixture. type: account.type ?? null, typeLabel: account.typeLabel ?? null, @@ -80,4 +82,35 @@ describe('financial/adapters/persistence/mappers/cedente-account.mapper', () => assert.equal(row.status, account.status); assert.equal(row.nextNsa, account.nextNsa); }); + + // #856 — o DV da agência atravessa os dois sentidos. Sem isto, o dígito seria aceito pela borda, + // sobreviveria ao domínio e morreria calado no mapper, que é o defeito que a issue descreve. + it('#856: o DV da agência faz round-trip domínio → row → domínio', () => { + const account: CedenteAccount = { ...buildAccount(), agencyDigit: '5' }; + assert.equal(toRow(account).agencyDigit, '5'); + + const back = toDomain(rowOf(account)); + assert.equal(back.ok, true); + if (back.ok) assert.equal(back.value.agencyDigit, '5'); + }); + + // Ausência é AUSÊNCIA nas duas direções: `null` na coluna vira campo ausente no agregado, e campo + // ausente vira `null` na coluna. É o que mantém "a agência não tem DV" distinto de "o DV é vazio". + it('#856: DV ausente vira null na row, e null volta como ausente', () => { + const account = buildAccount(); + assert.equal(toRow(account).agencyDigit, null); + + const back = toDomain(rowOf(account)); + assert.equal(back.ok, true); + if (back.ok) assert.equal(back.value.agencyDigit, undefined); + }); + + // Linha antiga com string vazia — o que uma versão anterior do front gravaria. A re-hidratação NÃO + // passa pelo construtor, então sem esta normalização a conta voltaria como "DV definido" e a + // edição recusaria preenchê-lo, devolvendo o operador ao beco da #942/#943. + it('#856: string vazia na coluna re-hidrata como ausente, não como dígito definido', () => { + const back = toDomain({ ...rowOf(buildAccount()), agencyDigit: ' ' }); + assert.equal(back.ok, true); + if (back.ok) assert.equal(back.value.agencyDigit, undefined); + }); }); diff --git a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts index ed93907a..a4815c71 100644 --- a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts +++ b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts @@ -4,7 +4,7 @@ import { strict as assert } from 'node:assert'; import { type Result, ok } from '#src/shared/index.ts'; import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; import { create as createCedente } from '#src/modules/financial/domain/cedente/cedente-account.ts'; -import { checkCedenteRemittanceReadiness } from '#src/modules/financial/domain/cedente/remittance-eligibility.ts'; +import { checkCedenteConvenio } from '#src/modules/financial/domain/cedente/remittance-eligibility.ts'; // W0 RED (019): o use-case editCedenteAccount ainda não existe. import { editCedenteAccount } from '#src/modules/financial/application/use-cases/edit-cedente-account.ts'; @@ -126,12 +126,17 @@ describe('edit-cedente-account — o convênio preenche, mas não troca (#722/#8 }); // A propriedade que amarra as duas metades, e a razão de a régua ser UMA só: o use case aceita a - // correção exatamente quando a remessa recusa a conta. Duas réguas para o mesmo fato divergiriam — - // é o defeito que a #837 fechou do outro lado deste módulo. - it('aceita a correção exatamente quando a remessa recusaria a conta', async () => { + // correção exatamente quando a remessa recusa a conta POR CAUSA DO CONVÊNIO. Duas réguas para o + // mesmo fato divergiriam — é o defeito que a #837 fechou do outro lado deste módulo. + // + // ⚠️ A régua é `checkCedenteConvenio`, e não a readiness inteira (#856). Desde que a agência entrou + // na readiness, usá-la aqui mediria outra coisa: uma conta de agência malformada responderia + // "a remessa recusa" e o teste passaria a exigir que a edição destravasse a troca de um convênio + // que está perfeito — o oposto do invariante do #722. + it('aceita a correção exatamente quando a remessa recusaria a conta pelo convênio', async () => { for (const convenio of ['', 'LEGADO', '9999999', 'ABC123']) { const account = buildAccount(convenio); - const readiness = checkCedenteRemittanceReadiness({ convenio }); + const readiness = checkCedenteConvenio({ convenio }); const r = await editConvenio(account, '123456'); assert.equal( @@ -142,3 +147,59 @@ describe('edit-cedente-account — o convênio preenche, mas não troca (#722/#8 } }); }); + +/** + * O DV DA AGÊNCIA na edição (#856 · #942/#943). + * + * O beco medido em produção: a conta migrada tem histórico e nasceu SEM dígito, porque não havia + * coluna. Sob a trava FR-008 pura, nenhuma conta em uso poderia ganhar o dado — o operador digita + * o DV, ele é descartado, e ao reabrir a tela o campo volta vermelho e desabilita o Salvar de TODOS + * os outros campos. É o mesmo desenho que a #722 deu ao convênio: PREENCHER o vazio destrava, + * TROCAR o que já existe continua sendo alteração de dado bancário. + */ +describe('edit-cedente-account — o DV da agência (#856)', () => { + const editAgencyDigit = async (account: unknown, agencyDigit: string, hasHistory = true) => + editCedenteAccount(deps(account, hasHistory) as never)({ + id: String((account as { id: unknown }).id), + agencyDigit, + }); + + it('preenche o DV ausente MESMO com histórico — é o que destrava a conta migrada', async () => { + const account = buildAccount('123456'); + const r = await editAgencyDigit(account, '5'); + assert.equal(r.ok, true, 'conta em uso tem de poder completar o cadastro'); + if (r.ok) assert.equal(r.value.agencyDigit, '5'); + }); + + it('TROCAR um DV já definido, com histórico, cai na trava de dado bancário', async () => { + const account = { ...buildAccount('123456'), agencyDigit: '5' }; + const r = await editAgencyDigit(account, '7'); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'cedente-account-bank-data-locked'); + }); + + it('sem histórico, trocar o DV é permitido como qualquer dado bancário', async () => { + const account = { ...buildAccount('123456'), agencyDigit: '5' }; + const r = await editAgencyDigit(account, '7', false); + assert.equal(r.ok, true); + if (r.ok) assert.equal(r.value.agencyDigit, '7'); + }); + + // Reenviar o mesmo valor não é troca. Sem isto, o front — que manda o formulário inteiro — + // receberia `bank-data-locked` ao salvar apenas o apelido de uma conta que já tem DV. + it('reenviar o MESMO DV não conta como alteração', async () => { + const account = { ...buildAccount('123456'), agencyDigit: '5' }; + const r = await editAgencyDigit(account, '5'); + assert.equal(r.ok, true); + }); + + // ⚠️ `''` é o que o front envia quando o operador não digitou DV. Tratá-lo como VALOR faria "sem + // dígito" virar "dígito definido", e a próxima tentativa de preencher cairia na trava — o beco de + // volta, por outra porta. + it('string vazia é ausência, não valor — não define nem apaga', async () => { + const account = buildAccount('123456'); + const r = await editAgencyDigit(account, ' '); + assert.equal(r.ok, true); + if (r.ok) assert.equal(r.value.agencyDigit, undefined); + }); +}); diff --git a/tests/modules/financial/application/use-cases/generate-remittance.test.ts b/tests/modules/financial/application/use-cases/generate-remittance.test.ts index ad5a3f3c..08498d88 100644 --- a/tests/modules/financial/application/use-cases/generate-remittance.test.ts +++ b/tests/modules/financial/application/use-cases/generate-remittance.test.ts @@ -57,7 +57,15 @@ const reader = (docs: readonly string[]): RemittancePaymentReader => ({ const approved = (ids: readonly string[]): Readonly> => Object.fromEntries(ids.map((id) => [id, 'Approved' as const])); -const setup = async (over: Partial<{ docs: readonly string[] }> = {}) => { +// `account` sobrescreve o cadastro do cedente — os campos que a #856 leva ao header (documento, +// agência, DV da agência) precisam variar por caso, e variá-los aqui é o que faz o teste medir o +// CAMINHO INTEIRO: cadastro → use case → emissor → posição no arquivo. +const setup = async ( + over: Partial<{ + docs: readonly string[]; + account: Partial<{ agency: string; agencyDigit: string; document: string }>; + }> = {}, +) => { const docs = over.docs ?? ['doc-1', 'doc-2']; const accounts = createInMemoryCedenteAccountStore(); @@ -76,6 +84,7 @@ const setup = async (over: Partial<{ docs: readonly string[] }> = {}) => { // O nome do BANCO, que é o que este campo significa. Enquanto ele guardava a razão social, o // emissor produzia o header certo por acidente — e o defeito ficava invisível na suíte. bankName: 'BRADESCO', + ...over.account, }); assert.ok(isOk(acc)); await accounts.save(acc.value); @@ -166,6 +175,165 @@ describe('generateRemittance — caminho feliz', () => { }); }); +/** + * OS CAMPOS DO CEDENTE NO HEADER (#856). + * + * ⚠️ ESTE BLOCO MEDE O ARQUIVO, e essa é a razão de ele viver aqui e não num teste de unidade do + * emissor. Os testes de `multipag-records` provam que o emissor escreve na posição certa o que lhe + * ENTREGAM; o defeito da #856 é anterior a isso — o use case entregava literais. Um teste que + * passasse `agencyDigit: '5'` ao emissor e conferisse a posição 058 ficaria verde durante os meses + * inteiros em que o campo saía em branco na produção. + * + * A régua é o registro tipo 0 (header de arquivo) e o tipo 1 (header de lote): os dois carregam o + * MESMO bloco de cedente, e um defeito que atingisse só um deles é o tipo de assimetria que ninguém + * procura. + */ +describe('generateRemittance — os campos do cedente no header (#856)', () => { + // Posições do layout são 1-based e inclusivas; `slice` é 0-based e exclusivo no fim. + const at = (line: string, from: number, to: number) => line.slice(from - 1, to); + + const headersOf = async ( + over: Parameters[0] = {}, + ): Promise => { + const s = await setup(over); + const r = await generateRemittance(s.deps)(input(s.cedenteAccountId, s.docs)); + assert.ok(isOk(r), `esperava ok, veio ${isErr(r) ? r.error : '?'}`); + + const stored = await s.storage.getText(onlyFile(r.value).objectKey); + assert.ok(isOk(stored)); + const lines = stored.value.split('\n').filter((l) => l.length > 0); + + const fileHeader = lines[0]; + const batchHeader = lines[1]; + assert.ok(fileHeader !== undefined && batchHeader !== undefined); + assert.equal(at(fileHeader, 8, 8), '0', 'primeira linha deve ser o header de arquivo'); + assert.equal(at(batchHeader, 8, 8), '1', 'segunda linha deve ser o header de lote'); + return [fileHeader, batchHeader]; + }; + + // ── 058 · G009 — DV da agência (CA2, ramo obrigatório) ────────────────────────────────────── + // + // O manual se contradiz sobre este campo e a contradição está registrada: `G009` (p. 95) o chama + // de "Campo Não Obrigatório – Informação Opcional", enquanto `G059 'AG'` (p. 107) e `'HD'` + // (p. 111) dizem que "o dígito da agência deve ser informado na posição 58". Quem paga é o + // validador — e, independentemente dele, o dado EXISTE no cadastro desde 25/08 e era descartado. + it('CA2: o DV da agência do cadastro chega às posições 058 dos dois headers', async () => { + const [fileHeader, batchHeader] = await headersOf({ account: { agencyDigit: '5' } }); + + assert.equal(at(fileHeader, 53, 57), '01234', 'a agência ocupa 053-057, e só ela'); + assert.equal(at(fileHeader, 58, 58), '5', 'header de arquivo: 058'); + assert.equal(at(batchHeader, 58, 58), '5', 'header de lote: 058'); + }); + + // Ausência continua saindo em branco, e isso é o layout — não desistência. `Alfa` vazio é brancos + // (p. 14), e a agência pode legitimamente não ter DV. + // + // ⚠️ NUNCA `'0'`. `05-armadilhas-e-divergencias.md` §2: "se o DV for `0`, enviar `0`; se a agência + // realmente não tiver DV, enviar branco. Nunca zero por padrão sem confirmar". Zero é um dígito + // afirmado, e afirmar o dígito errado é pior que não afirmar nenhum. + it('CA2: conta sem DV cadastrado sai com BRANCO na 058 — nunca zero por omissão', async () => { + const [fileHeader, batchHeader] = await headersOf(); + + assert.equal(at(fileHeader, 58, 58), ' '); + assert.equal(at(batchHeader, 58, 58), ' '); + }); + + // ── 072 · G012 — DV agência/conta (CA2, ramo facultativo: o branco JUSTIFICADO) ────────────── + // + // `G012` (p. 96) define o campo como a **2ª posição do DV** para bancos cujo dígito de conta tem + // duas posições — o exemplo do manual é `45981-36`, com `3` na 071 e `6` na 072. O DV de conta do + // Bradesco tem UMA posição: `bradescoAccountCheckDigits` (Manual de Procedimentos 4008-523-0096 + // v16, p. 30) devolve um único caractere. Não há segunda posição a gravar. + // + // Medido do outro lado também: a inquiry-0033 submeteu 18 arquivos ao Validador Universal em + // 25/08/2026 com os DVs de agência/conta vazios em três cenários, e NENHUMA crítica a eles. + // + // Este teste existe para que o branco continue sendo uma DECISÃO: quem um dia resolver preencher + // a 072 vai encontrar aqui a razão pela qual ela está vazia, em vez de concluir que foi esquecida. + it('CA2: a 072 fica em BRANCO — o Bradesco não tem 2ª posição de DV de conta', async () => { + const [fileHeader, batchHeader] = await headersOf({ account: { agencyDigit: '5' } }); + + assert.equal(at(fileHeader, 71, 71), '1', 'a 071 leva o DV da conta, que existe'); + assert.equal(at(fileHeader, 72, 72), ' '); + assert.equal(at(batchHeader, 72, 72), ' '); + }); + + // ── 018 · G005 — tipo de inscrição (CA4) ──────────────────────────────────────────────────── + it('CA4: cedente com CNPJ sai com tipo de inscrição 2', async () => { + const [fileHeader, batchHeader] = await headersOf({ + account: { document: '12345678000199' }, + }); + + assert.equal(at(fileHeader, 18, 18), '2'); + assert.equal(at(batchHeader, 18, 18), '2'); + }); + + // O caso que o literal `'2'` errava. Um cedente pessoa física saía declarado pessoa jurídica: o + // arquivo é bem-formado, o banco não recusa, e o `G005` simplesmente não descreve o titular. + it('CA4: cedente com CPF sai com tipo de inscrição 1 — era onde o literal mentia', async () => { + const [fileHeader, batchHeader] = await headersOf({ account: { document: '12345678909' } }); + + assert.equal(at(fileHeader, 18, 18), '1'); + assert.equal(at(batchHeader, 18, 18), '1'); + }); + + // A inscrição de 11 posições ocupa o campo de 14 com zeros à esquerda (G006). Vai junto porque é + // o par do caso acima: tipo `1` com a inscrição zerada errada seria um header coerente e falso. + it('CA4: o CPF ocupa 019-032 com zeros à esquerda, ao lado do tipo 1', async () => { + const [fileHeader] = await headersOf({ account: { document: '12345678909' } }); + + assert.equal(at(fileHeader, 19, 32), '00012345678909'); + }); + + // A máscara não muda o tipo: quem mede o comprimento é a inscrição NORMALIZADA. Sem isto, + // `123.456.789-09` teria 14 caracteres e seria classificado como pessoa jurídica. + it('CA4: CPF com máscara no cadastro ainda é tipo 1', async () => { + const [fileHeader] = await headersOf({ account: { document: '123.456.789-09' } }); + + assert.equal(at(fileHeader, 18, 18), '1'); + }); + + // ── A recusa que impede a correção silenciosa (CA2, o irmão da #859) ──────────────────────── + // + // A saída "óbvia" para não perder o DV é gravá-lo dentro de `agency`, que aceita 10 caracteres na + // borda. `digits('01234-5', 5)` removeria o separador e gravaria `12345` em 053-057, onde o banco + // espera `01234` — cinco dígitos, cabe no campo, nenhum gate acusa, e toda remessa daquela conta + // vai ao banco apontando outra agência. Recusar é a política da #804: nunca truncar. + it('CA2: agência com separador RECUSA a geração, e antes de queimar NSA', async () => { + const s = await setup({ account: { agency: '01234-5' } }); + const r = await generateRemittance(s.deps)(input(s.cedenteAccountId, s.docs)); + + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-agency-malformed'); + + const acc = await s.accounts.findById(s.cedenteAccountId); + assert.ok(isOk(acc) && acc.value !== null); + assert.equal(acc.value.nextNsa, 1, 'a recusa não pode consumir um número da sequência'); + }); + + // ── CA3 — o CNPJ alfanumérico do cedente ──────────────────────────────────────────────────── + // + // O emissor grava 019-032 com um helper que remove tudo que não é dígito. Sobre uma inscrição + // ALFANUMÉRICA (válida desde 07/2026, ADR-0044) isso não tira máscara: destrói conteúdo, e + // `12ABC34501DE35` vira `00000123450135` — catorze dígitos, campo `Num` perfeito, arquivo aceito, + // e o cedente declarado não é o titular da conta que paga. + // + // ⚠️ A recusa é do PRÉ-VOO, e é isso que este caso mede: o emissor monta depois do `allocateNsa`, + // então uma barreira só lá dentro custaria um número da sequência por tentativa e chegaria ao + // operador como falha genérica de montagem, sem apontar campo nenhum. + it('CA3: cedente com CNPJ alfanumérico RECUSA com slug próprio, sem zero-padding e sem NSA', async () => { + const s = await setup({ account: { document: '12ABC34501DE35' } }); + const r = await generateRemittance(s.deps)(input(s.cedenteAccountId, s.docs)); + + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-inscription-alphanumeric'); + + const acc = await s.accounts.findById(s.cedenteAccountId); + assert.ok(isOk(acc) && acc.value !== null); + assert.equal(acc.value.nextNsa, 1, 'a recusa não pode consumir um número da sequência'); + }); +}); + /** * A costura da referência de retorno (#752) — o casamento referência ↔ documento. * diff --git a/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts b/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts index f1c6b6de..f2a0dbe3 100644 --- a/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts +++ b/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts @@ -3,7 +3,12 @@ import { strict as assert } from 'node:assert'; import { isErr, isOk } from '#src/shared/index.ts'; // W0 RED: a aptidão da conta-cedente à remessa ainda não é verificável. -import { checkCedenteRemittanceReadiness } from '#src/modules/financial/domain/cedente/remittance-eligibility.ts'; +import { + checkCedenteAgency, + checkCedenteConvenio, + checkCedenteInscription, + checkCedenteRemittanceReadiness, +} from '#src/modules/financial/domain/cedente/remittance-eligibility.ts'; /** * A conta-cedente está apta a GERAR remessa? (#722) @@ -15,7 +20,13 @@ import { checkCedenteRemittanceReadiness } from '#src/modules/financial/domain/c * operador como 503 genérico: nada indica que falta um campo, nem em qual tela preenchê-lo. */ -const account = (convenio: string) => ({ convenio }); +// Agência e inscrição sintéticas e VÁLIDAS em todos os casos de convênio: desde a #856 a readiness +// olha os três campos, e um fixture inválido faria os casos de convênio passarem pelo motivo errado. +const account = (convenio: string) => ({ + convenio, + agency: '01234', + document: '12345678000199', +}); describe('Aptidão da conta-cedente à remessa (#722)', () => { it('conta com convênio numérico está apta', () => { @@ -63,3 +74,124 @@ describe('Aptidão da conta-cedente à remessa (#722)', () => { assert.ok(isOk(checkCedenteRemittanceReadiness(account(' 000000 ')))); }); }); + +/** + * A AGÊNCIA do cedente — posições 053-057 (#856, herdado da #859). + * + * O que torna este bloco diferente do de cima: o convênio ausente tem uma barreira adiante (o + * montador do nome do arquivo); a agência corrompida NÃO TEM NENHUMA. `digits()` remove o separador + * e o resultado cabe no campo, então nem `numeric-field-overflow` nem o `remittance-inspector` — + * que valida forma, e a forma fica perfeita — enxergam o defeito. O arquivo vai ao banco apontando + * outra agência, em toda remessa daquela conta. + */ +describe('Aptidão da conta-cedente à remessa — a agência (#856)', () => { + const withAgency = (agency: string) => ({ + convenio: '000000', + agency, + document: '12345678000199', + }); + + it('agência de até 5 dígitos está apta', () => { + for (const agency of ['1', '1234', '01234', '99999']) { + assert.ok(isOk(checkCedenteAgency({ agency })), agency); + } + }); + + it('agência ausente é lacuna NOMEADA', () => { + for (const blank of ['', ' ', '\t']) { + const r = checkCedenteAgency({ agency: blank }); + assert.ok(isErr(r), JSON.stringify(blank)); + assert.equal(r.error, 'cedente-agency-missing'); + } + }); + + // ⚠️ O CASO QUE ESTE BLOCO EXISTE PARA FIXAR. O operador digita `0288-2` na tela; se o separador + // sobreviver até o cadastro, `digits('01234-5', 5)` devolve `12345` — cinco dígitos, cabe, ninguém + // reclama — onde o banco espera `01234`. Recusar é a única saída que não adivinha qual metade é a + // agência: `12345` é `1234`+`5` ou `12345` sem DV? A #708 já decidiu que isso não se resolve por + // palpite, e sim com campo próprio — o `agencyDigit`. + it('agência com separador é RECUSADA, nunca truncada em silêncio', () => { + for (const agency of ['01234-5', '0123/4', '1.234']) { + const r = checkCedenteAgency({ agency }); + assert.ok(isErr(r), agency); + assert.equal(r.error, 'cedente-agency-malformed', agency); + } + }); + + it('agência acima de 5 posições é recusada ANTES do NSA, e não no montador', () => { + const r = checkCedenteAgency({ agency: '123456' }); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-agency-malformed'); + }); + + it('a readiness completa cobra as duas coisas — convênio bom não absolve agência ruim', () => { + const r = checkCedenteRemittanceReadiness(withAgency('01234-5')); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-agency-malformed'); + }); + + // O contrato que a edição de conta-cedente depende: perguntar pelo convênio NÃO pode responder + // sobre a agência. Sem esta separação, uma conta de agência malformada destravaria a troca de um + // convênio perfeito — que é o invariante do #722. + it('a régua do convênio ignora a agência, e é por isso que ela existe separada', () => { + assert.ok(isOk(checkCedenteConvenio({ convenio: '000000' }))); + }); +}); + +/** + * A INSCRIÇÃO do cedente — 019-032, `G006` (#856, CA3). + * + * O que este bloco impede é a mesma classe de defeito da agência, num campo mais grave: o emissor + * grava a inscrição com um helper que remove tudo que não é dígito. Sobre `12ABC34501DE35` isso não + * tira máscara — destrói conteúdo, e devolve `00000123450135`: catorze dígitos, campo `Num` válido, + * arquivo aceito, cedente declarado que não é o titular da conta que paga. + * + * O CNPJ alfanumérico é emitido pela Receita desde 07/2026 (ADR-0044); o layout do banco é de + * jul/2025 e declara o campo `Num`. Recusar com nome próprio é a única saída que não inventa layout. + */ +describe('Aptidão da conta-cedente à remessa — a inscrição (#856 CA3)', () => { + it('CNPJ e CPF numéricos estão aptos', () => { + assert.ok(isOk(checkCedenteInscription({ document: '12345678000199' }))); + assert.ok(isOk(checkCedenteInscription({ document: '12345678909' }))); + }); + + it('máscara não é defeito — ela é tirada, não recusada', () => { + assert.ok(isOk(checkCedenteInscription({ document: '12.345.678/0001-99' }))); + }); + + // ⚠️ O caso central: RECUSA, jamais zero-padding do que sobrou das letras. + it('CNPJ alfanumérico é RECUSADO com slug próprio, nunca truncado em silêncio', () => { + const r = checkCedenteInscription({ document: '12ABC34501DE35' }); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-inscription-alphanumeric'); + }); + + it('inscrição ausente é lacuna distinta — o operador preenche, e é outra ação', () => { + for (const blank of ['', ' ']) { + const r = checkCedenteInscription({ document: blank }); + assert.ok(isErr(r), JSON.stringify(blank)); + assert.equal(r.error, 'cedente-inscription-missing'); + } + }); + + // ⚠️ `'---'` sobrevive ao `trim()` e normaliza para vazio. Uma régua que perguntasse pelo trim o + // veria como inscrição PRESENTE e — não sendo numérico — o chamaria de alfanumérica, mandando + // escalar ao Bradesco um cadastro que só está incompleto. + it('só-pontuação é AUSENTE, não alfanumérico — a distinção decide para quem o chamado vai', () => { + for (const punctuation of ['---', '.', './-']) { + const r = checkCedenteInscription({ document: punctuation }); + assert.ok(isErr(r), punctuation); + assert.equal(r.error, 'cedente-inscription-missing', punctuation); + } + }); + + it('a readiness completa cobra a inscrição junto com o resto', () => { + const r = checkCedenteRemittanceReadiness({ + convenio: '000000', + agency: '01234', + document: '12ABC34501DE35', + }); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-inscription-alphanumeric'); + }); +}); diff --git a/tests/modules/financial/domain/payout/inscription.test.ts b/tests/modules/financial/domain/payout/inscription.test.ts new file mode 100644 index 00000000..77202e59 --- /dev/null +++ b/tests/modules/financial/domain/payout/inscription.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + inscriptionType, + isCnabEmittableInscription, + normalizeInscription, +} from '#src/modules/financial/domain/payout/inscription.ts'; + +/** + * A inscrição (CPF/CNPJ) como o arquivo do banco precisa dela — fonte única (#856). + * + * O que estes casos protegem não é a aritmética do comprimento: é a UNICIDADE da régua. O emissor + * afirmava `'2'` literal para o cedente enquanto o reader MEDIA o mesmo fato para o favorecido, e as + * duas decisões conviviam sem se citar. Com uma função só, mexer nela reclassifica os dois lados do + * arquivo de uma vez — e é isso que estes testes fixam. + */ +describe('normalizeInscription — a máscara sai, as letras ficam', () => { + it('remove pontuação de CPF e CNPJ', () => { + assert.equal(normalizeInscription('123.456.789-09'), '12345678909'); + assert.equal(normalizeInscription('12.345.678/0001-99'), '12345678000199'); + }); + + // ⚠️ ADR-0044: a Receita emite CNPJ alfanumérico desde 07/2026 — 12 posições alfanuméricas mais 2 + // DVs numéricos. Um filtro `\D` aqui destruiria conteúdo em vez de tirar máscara, e o resultado + // seria uma inscrição diferente com aparência perfeitamente válida. + it('PRESERVA as letras do CNPJ alfanumérico — não é máscara, é conteúdo', () => { + assert.equal(normalizeInscription('12ABC34501DE35'), '12ABC34501DE35'); + assert.equal(normalizeInscription('12.ABC.345/01DE-35'), '12ABC34501DE35'); + }); + + // `12abc3` e `12ABC3` são a mesma inscrição; deixar as duas formas circularem produziria + // comparações que falham por caixa. + it('normaliza para maiúsculas, como a RFB define', () => { + assert.equal(normalizeInscription('12abc34501de35'), '12ABC34501DE35'); + }); +}); + +describe('inscriptionType — G005, e por que ele mede em vez de afirmar', () => { + it('11 posições é pessoa física', () => { + assert.equal(inscriptionType('12345678909'), '1'); + }); + + it('14 posições é pessoa jurídica', () => { + assert.equal(inscriptionType('12345678000199'), '2'); + }); + + // O caso que o literal `'2'` do cedente errava: a máscara não muda quem é o titular. + it('mede sobre a inscrição NORMALIZADA — máscara não reclassifica ninguém', () => { + assert.equal(inscriptionType('123.456.789-09'), '1'); + assert.equal(inscriptionType('12.345.678/0001-99'), '2'); + }); + + // ⚠️ A dependência entre as duas funções é real, e este caso a fixa: se a normalização voltasse a + // filtrar `\D`, `12ABC34501DE35` encolheria para 11 dígitos e um CNPJ viraria pessoa física — + // tipo errado num arquivo que o banco aceita sem reclamar. + it('CNPJ alfanumérico continua sendo pessoa jurídica', () => { + assert.equal(inscriptionType('12ABC34501DE35'), '2'); + }); + + // Uma inscrição alfanumérica tem tipo `2` E é inemissível num campo `Num` — as duas perguntas são + // independentes, e esta função responde só a primeira. Quem responde a segunda é a #863. + it('não opina sobre emissibilidade: comprimento é a única pergunta que ele responde', () => { + assert.equal(inscriptionType(''), '2'); + assert.equal(inscriptionType('---'), '2'); + }); +}); + +describe('isCnabEmittableInscription — cabe num campo `Num` sem virar OUTRA inscrição?', () => { + it('inscrição numérica cabe, com ou sem máscara', () => { + assert.equal(isCnabEmittableInscription('12345678000199'), true); + assert.equal(isCnabEmittableInscription('12.345.678/0001-99'), true); + assert.equal(isCnabEmittableInscription('12345678909'), true); + }); + + // O helper posicional do emissor removeria as letras e devolveria `00000123450135` — catorze + // dígitos, campo válido, arquivo aceito pelo banco, e uma inscrição que não é a do titular. É por + // isso que a resposta é `false` e não uma normalização "esperta". + it('inscrição alfanumérica NÃO cabe — remover as letras produziria outra inscrição', () => { + assert.equal(isCnabEmittableInscription('12ABC34501DE35'), false); + }); + + it('vazio não cabe, e só-pontuação também é vazio', () => { + for (const empty of ['', ' ', '---', './-']) { + assert.equal(isCnabEmittableInscription(empty), false, JSON.stringify(empty)); + } + }); +}); From cfc8d47a696b9e8d845589b49426951c3b1fb2a6 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 11:38:01 -0300 Subject: [PATCH 04/13] =?UTF-8?q?fix(financial):=20o=20DV=20da=20ag=C3=AAn?= =?UTF-8?q?cia=20sobrevive=20ao=20UPDATE,=20e=20para=20de=20ser=20truncado?= =?UTF-8?q?=20(#856)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../financial/adapters/http/error-mapping.ts | 6 ++- .../financial/adapters/http/schemas.ts | 12 ++++-- .../mappers/cedente-account.mapper.ts | 8 +++- .../repos/cedente-account-store.drizzle.ts | 9 ++++ .../adapters/persistence/schemas/mysql.ts | 6 +-- .../use-cases/edit-cedente-account.ts | 2 +- .../domain/cedente/remittance-eligibility.ts | 34 +++++++++++++-- .../financial/domain/payout/payee-account.ts | 4 +- tests/etl/fixtures/legacy-mini.sql | 2 +- .../http/error-classification.test.ts | 3 +- .../cedente-account-store.contract.ts | 38 ++++++++++++++++ .../mappers/cedente-account.mapper.test.ts | 17 ++++++++ .../cedente/remittance-eligibility.test.ts | 43 ++++++++++++++++++- 13 files changed, 165 insertions(+), 19 deletions(-) diff --git a/src/modules/financial/adapters/http/error-mapping.ts b/src/modules/financial/adapters/http/error-mapping.ts index dd156b49..68351a51 100644 --- a/src/modules/financial/adapters/http/error-mapping.ts +++ b/src/modules/financial/adapters/http/error-mapping.ts @@ -272,12 +272,14 @@ const SLUG_MESSAGES: Record = { 'cedente-convenio-already-set': 'Esta conta já tem convênio. Ele identifica o contrato junto ao banco e não pode ser trocado.', // #856 · #859. Também campo do CADASTRO da conta. A mensagem do malformado diz o que fazer com o - // dígito de propósito: o caso real é o operador ter digitado `0288-2` num campo que é só a agência, - // e mandá-lo "corrigir a agência" sem dizer onde o dígito vai o faria apagar o dado. + // dígito de propósito: o caso real é o operador ter digitado a agência COM o dígito num campo que + // é só a agência, e mandá-lo "corrigir a agência" sem dizer onde o dígito vai o faria apagar o dado. 'cedente-agency-missing': 'A conta bancária selecionada está sem a agência. Informe-a no cadastro da conta para gerar remessas.', 'cedente-agency-malformed': 'A agência da conta bancária selecionada não é um número de até 5 dígitos. Informe apenas os dígitos da agência, sem traço nem ponto — o dígito verificador tem campo próprio no cadastro.', + 'cedente-agency-digit-malformed': + 'O dígito verificador da agência precisa ser um único caractere (0 a 9, X ou P). Corrija-o no cadastro da conta.', // #856 CA3. As duas frases terminam em AÇÕES opostas, e é a ação que o operador precisa ler: a // primeira se resolve no cadastro, a segunda NÃO se resolve em lugar nenhum do ERP — o dado está // certo e o layout do banco é que é anterior ao CNPJ alfanumérico (ADR-0044). diff --git a/src/modules/financial/adapters/http/schemas.ts b/src/modules/financial/adapters/http/schemas.ts index 0ae16fd5..d80c0b47 100644 --- a/src/modules/financial/adapters/http/schemas.ts +++ b/src/modules/financial/adapters/http/schemas.ts @@ -850,10 +850,15 @@ export const createCedenteAccountBodySchema = z.object({ agency: z.string().min(1).max(10), // #856 · #859 — DV da agência, posição 058 do header CNAB. OPCIONAL porque a agência pode não ter // DV; separado de `agency` porque o emissor remove o separador antes de gravar as posições - // 053-057, e `0288-2` num campo só vira `02882` onde o banco espera `00288`. + // 053-057, e um `1234-5` num campo só vira `12345` onde o banco espera `01234`. + // + // ⚠️ UMA posição, e não duas como o DV da CONTA. `alpha()` faz `.slice(0, size)`: um `max(2)` aqui + // aceitaria `5X` e gravaria `5`, e `-2` gravaria um `-` literal na 058 — aceitar-e-truncar, que é + // exatamente o defeito que esta issue existe para eliminar. O DV da conta tem 2 porque o layout + // prevê DV de duas posições (G011 + G012); o da agência, não. agencyDigit: z .string() - .max(2) + .max(1) .optional() .meta({ description: 'Dígito verificador da agência (posição 058 do CNAB). Sem separador.' }), accountNumber: z.string().min(1).max(20), @@ -875,7 +880,8 @@ export const editCedenteAccountBodySchema = z.object({ agency: z.string().min(1).max(10).optional(), // #856: preenchível na conta já cadastrada, que nasceu sem DV porque não havia coluna. TROCAR um // DV já definido continua caindo na trava de dado bancário (FR-008) — ver `edit-cedente-account`. - agencyDigit: z.string().max(2).optional(), + // `max(1)` pela razão do create: a 058 tem uma posição, e `alpha()` truncaria a segunda em silêncio. + agencyDigit: z.string().max(1).optional(), accountNumber: z.string().min(1).max(20).optional(), accountDigit: z.string().max(2).optional(), type: accountTypeSchema.optional(), diff --git a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts index cd1bdea9..5cd6251d 100644 --- a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts +++ b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts @@ -68,8 +68,14 @@ export const toDomain = ( // `''` gravado por uma versão anterior colapsa em ausente aqui também — a re-hidratação não // passa pelo construtor, e sem esta linha uma linha antiga com string vazia voltaria como // "dígito definido", trancando o preenchimento pela edição. + // + // ⚠️ O VALOR GRAVADO É O APARADO, e não o bruto da coluna. Guardar `' 5'` produzia DOIS + // defeitos de uma linha só: `text(' 5', 1)` devolve `' '`, então a 058 sai em BRANCO com o + // dígito presente no banco; e `wantsAgencyDigitSwap` compara contra o `'5'` que o construtor e + // a edição gravam, lendo um reenvio idêntico como TROCA e disparando a trava FR-008. As três + // cópias da mesma regra — construtor, edição e aqui — têm de aparar igual. ...(row.agencyDigit !== null && row.agencyDigit.trim() !== '' - ? { agencyDigit: row.agencyDigit } + ? { agencyDigit: row.agencyDigit.trim() } : {}), ...(row.type !== null ? { type: row.type as AccountType } : {}), ...(row.typeLabel !== null ? { typeLabel: row.typeLabel } : {}), diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index 5c74d0ff..434bd9cf 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -144,6 +144,15 @@ export const createDrizzleCedenteAccountStore = ( set: { bankCode: row.bankCode, agency: row.agency, + // ⚠️ ESTA LISTA É ESCRITA À MÃO, e o que falta nela some no UPDATE sem erro nenhum — + // o `values(row)` acima grava a coluna no INSERT, então a conta NOVA sai certa e a + // EDIÇÃO não. O use case devolve 200 com o dígito ecoado do agregado em memória + // enquanto a coluna continua NULL, e a 058 fica em branco para sempre (#856). + // + // Coluna nova aqui é coluna nova nesta lista. `$inferInsert` torna coluna nullable + // OPCIONAL no tipo, então o compilador não cobra a ausência — quem cobra é + // `cedente-account-store.contract.ts`, que passou a conferir o UPDATE campo a campo. + agencyDigit: row.agencyDigit, accountNumber: row.accountNumber, accountDigit: row.accountDigit, convenio: row.convenio, diff --git a/src/modules/financial/adapters/persistence/schemas/mysql.ts b/src/modules/financial/adapters/persistence/schemas/mysql.ts index 0e60dd62..2375f65d 100644 --- a/src/modules/financial/adapters/persistence/schemas/mysql.ts +++ b/src/modules/financial/adapters/persistence/schemas/mysql.ts @@ -653,9 +653,9 @@ export const finCedenteAccounts = mysqlTable( // a agência pode legitimamente não ter DV, e toda conta já cadastrada nasceu sem ele (não havia // coluna, e o front descartava o dígito que o operador digitava). // - // ⚠️ COLUNA PRÓPRIA, e não sufixo de `agency`. `agency` tem 12 posições e caberia `0288-2` — mas - // o emissor escreve `digits(agency, 5)`, que remove o separador e grava `02882` nas posições - // 053-057, onde o banco espera `00288`. Nenhum gate acusa. Ver `remittance-eligibility.ts`. + // ⚠️ COLUNA PRÓPRIA, e não sufixo de `agency`. `agency` tem 12 posições e caberia `1234-5` — mas + // o emissor escreve `digits(agency, 5)`, que remove o separador e grava `12345` nas posições + // 053-057, onde o banco espera `01234`. Nenhum gate acusa. Ver `remittance-eligibility.ts`. // // ⚠️ FORA da UNIQUE de chave natural, de propósito: a identidade da conta é banco+agência+conta // +dígito da CONTA (FR-016), e o DV da agência é atributo dela, não parte de quem ela é. Incluí-lo diff --git a/src/modules/financial/application/use-cases/edit-cedente-account.ts b/src/modules/financial/application/use-cases/edit-cedente-account.ts index 97fe5dbb..96e79646 100644 --- a/src/modules/financial/application/use-cases/edit-cedente-account.ts +++ b/src/modules/financial/application/use-cases/edit-cedente-account.ts @@ -20,7 +20,7 @@ export type EditCedenteAccountInput = Readonly<{ // Preenchível quando ausente, pelo MESMO desenho do convênio abaixo (#856). A conta migrada nasceu // sem DV porque não havia coluna, e ela tem histórico — sob a trava FR-008 pura, nenhuma conta em // uso poderia ganhar o dígito, que é precisamente o beco medido em produção (#942/#943): o - // operador digita `0288-2`, o dígito é descartado, e ao reabrir a tela o campo volta vermelho e + // operador digita a agência com o DV, o dígito é descartado, e ao reabrir a tela o campo fica vermelho e // trava o Salvar de TODOS os outros campos. Preencher o vazio destrava; TROCAR um DV já definido // continua sendo alteração de dado bancário e cai na trava. agencyDigit?: string; diff --git a/src/modules/financial/domain/cedente/remittance-eligibility.ts b/src/modules/financial/domain/cedente/remittance-eligibility.ts index e86cbafe..4956f7fe 100644 --- a/src/modules/financial/domain/cedente/remittance-eligibility.ts +++ b/src/modules/financial/domain/cedente/remittance-eligibility.ts @@ -22,7 +22,7 @@ import { isCnabEmittableInscription, normalizeInscription } from '../payout/insc // // ⚠️ A #856 acrescentou a AGÊNCIA, e ela quebra a simetria acima: o convênio ausente tem uma última // barreira adiante — o montador do nome —, a agência corrompida NÃO TEM NENHUMA. Aqui é a única -// barreira que existe entre um `0288-2` digitado no cadastro e um arquivo que credita outra agência. +// barreira entre uma agência digitada com o DV junto e um arquivo que credita outra agência. export type CedenteConvenioGap = | 'cedente-convenio-missing' @@ -36,7 +36,13 @@ export type CedenteConvenioGap = // #856 · #859. A AGÊNCIA é o segundo campo do cedente que o arquivo lê da conta, e o único cuja // falha é SILENCIOSA: o convênio ausente estoura no montador do nome, a agência corrompida não // estoura em lugar nenhum. Ver a constante `AGENCY_WIDTH` abaixo para o mecanismo. -export type CedenteAgencyGap = 'cedente-agency-missing' | 'cedente-agency-malformed'; +export type CedenteAgencyGap = + | 'cedente-agency-missing' + | 'cedente-agency-malformed' + // O DV, quando existe, tem de caber na 058 — UMA posição, e do alfabeto do manual. Desfecho + // separado porque o campo que o operador corrige é OUTRO: um é o número, outro é o dígito, e são + // duas caixas distintas na tela. + | 'cedente-agency-digit-malformed'; // #856 CA3. A INSCRIÇÃO do cedente — 019-032, `G006`. Os dois desfechos terminam em ações opostas, e // é por isso que não se juntam: o ausente o operador preenche no cadastro; o alfanumérico ninguém @@ -99,19 +105,39 @@ export const checkCedenteConvenio = ( // ambiguidade não se resolve por palpite — resolve-se com campo próprio, que é o `agencyDigit`. const AGENCY_WIDTH = 5; +// O DV cabe em UMA posição (058), e o alfabeto é o que `payee-account.ts` documenta contra o manual: +// dígito, `X` onde o módulo 11 dá resto 10, e `P` no Bradesco quando o resto é 1 — "o dígito poderá +// ser igual a zero ou 'P'" (Manual de Procedimentos 4008-523-0096 v16, p. 30). +// +// ⚠️ A RECUSA EXISTE PORQUE `alpha()` NÃO RECUSA: ele faz `.slice(0, size)`, então um DV de dois +// caracteres é gravado pela metade e um `-2` grava um `-` literal na 058 — as duas coisas em +// silêncio, num campo de identificação bancária. A borda barra o comprimento (`schemas.ts`); esta é +// a régua para quem NÃO passa por ela: o ETL e a linha re-hidratada, que não vê construtor. +const AGENCY_CHECK_DIGIT_RE = /^[0-9XP]$/; + export const checkCedenteAgency = ( - account: Readonly<{ agency: string }>, + account: Readonly<{ agency: string; agencyDigit?: string }>, ): Result => { const agency = account.agency.trim(); if (agency === '') return err('cedente-agency-missing'); // Não-numérico é, na prática, agência com separador — o caso que o front descreve na #859, em que - // o operador digita `0288-2` e a base e o dígito acabam no mesmo campo. + // o operador digita a agência com o dígito e os dois acabam no mesmo campo. if (!NUMERIC_ONLY.test(agency)) return err('cedente-agency-malformed'); // Estouro tem o mesmo desfecho de propósito: `num()` já o recusaria no montador, mas lá o NSA já // foi queimado. Aqui é antes, e a ação do operador — conferir a agência no cadastro — é a mesma. if (agency.length > AGENCY_WIDTH) return err('cedente-agency-malformed'); + // O DV vem DEPOIS do número, e a ordem é a que o operador precisa: sem agência não há dígito de + // que falar, e reportar o acessório antes do principal manda corrigir a caixa errada. + // + // Ausente é legítimo — a agência pode não ter DV, e a 058 sai em branco (layout p. 14). Só o que + // ESTÁ lá é conferido. + const agencyDigit = account.agencyDigit?.trim() ?? ''; + if (agencyDigit !== '' && !AGENCY_CHECK_DIGIT_RE.test(agencyDigit.toUpperCase())) { + return err('cedente-agency-digit-malformed'); + } + return ok(undefined); }; diff --git a/src/modules/financial/domain/payout/payee-account.ts b/src/modules/financial/domain/payout/payee-account.ts index 8a19b500..1b63c3cf 100644 --- a/src/modules/financial/domain/payout/payee-account.ts +++ b/src/modules/financial/domain/payout/payee-account.ts @@ -83,8 +83,8 @@ export type CheckDigitSplit = Readonly<{ base: string; digit: string | null }>; // descarte da segunda é do layout, não nosso: o campo tem uma posição só. // // ⚠️ EXPORTADA na #856, e o segundo chamador é o ETL. A agência legada vem com o DV embutido -// (`0288-7`, evidência F7) e era gravada INTEIRA na coluna `agency` — de onde `digits(agency, 5)` -// removia o separador e escrevia `02887` nas posições 053-057, onde o banco espera `00288`. A mesma +// (formato `NNNN-D`, evidência F7) e era gravada INTEIRA na coluna `agency` — de onde +// `digits(agency, 5)` removia o separador e escrevia `12345` onde o banco espera `01234`. A mesma // gramática que decompõe a conta do favorecido decompõe a agência do cedente; escrever uma segunda // no `scripts/` faria as duas divergirem no dia em que uma mudasse. export const splitCheckDigit = (raw: string): CheckDigitSplit | null => { diff --git a/tests/etl/fixtures/legacy-mini.sql b/tests/etl/fixtures/legacy-mini.sql index 672f3af0..6669de66 100644 --- a/tests/etl/fixtures/legacy-mini.sql +++ b/tests/etl/fixtures/legacy-mini.sql @@ -193,7 +193,7 @@ CREATE TABLE `accounts` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO `accounts` VALUES - (1,'CONTA FAKE','BRADESCO','0288-7','12345','0',1000.50,'2024-01-01 12:00:00','2024-01-02 12:00:00'); + (1,'CONTA FAKE','BRADESCO','1234-5','12345','0',1000.50,'2024-01-01 12:00:00','2024-01-02 12:00:00'); CREATE TABLE `payables` ( `id` int NOT NULL AUTO_INCREMENT, diff --git a/tests/modules/financial/adapters/http/error-classification.test.ts b/tests/modules/financial/adapters/http/error-classification.test.ts index 6e2b5822..e7f10532 100644 --- a/tests/modules/financial/adapters/http/error-classification.test.ts +++ b/tests/modules/financial/adapters/http/error-classification.test.ts @@ -94,6 +94,7 @@ describe('error-mapping — as lacunas da conta-cedente têm mensagem própria ( const slugs = [ 'cedente-agency-missing', 'cedente-agency-malformed', + 'cedente-agency-digit-malformed', 'cedente-inscription-missing', 'cedente-inscription-alphanumeric', ]; @@ -111,7 +112,7 @@ describe('error-mapping — as lacunas da conta-cedente têm mensagem própria ( }); } - it('as quatro mensagens são DISTINTAS entre si', () => { + it('as mensagens são DISTINTAS entre si', () => { const messages = new Set(slugs.map((s) => toPublicMessage(s))); assert.equal(messages.size, slugs.length); }); diff --git a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts index 859b3017..7c1b940a 100644 --- a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts +++ b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts @@ -113,6 +113,44 @@ export const cedenteAccountStoreContract = ( assert.equal(found.value.nickname, 'apelido novo'); }); + /* + * ⚠️ O UPDATE É CONFERIDO CAMPO A CAMPO, e a razão é um defeito medido (#856). + * + * O adapter Drizzle faz `onDuplicateKeyUpdate({ set: {...} })` com a lista de colunas escrita À + * MÃO. O `values(row)` do INSERT vem de `toRow` e ganha coluna nova de graça; o `set` não. Uma + * coluna esquecida ali é gravada na conta NOVA e descartada na EDIÇÃO — o use case devolve 200 + * com o valor ecoado do agregado em memória, e a coluna fica NULL. + * + * E o compilador não cobra: `$inferInsert` torna coluna nullable OPCIONAL no tipo. Nem o fake + * cobra: o in-memory substitui o objeto inteiro por spread, então ele acerta sempre. O caso + * acima passava verde asserindo `status` e `nickname` — por acaso, as duas colunas que ESTAVAM + * na lista. + * + * Este caso é a rede: ele varre o snapshot inteiro, então toda coluna futura entra sozinha. + */ + it('save em id existente preserva TODOS os campos, não só os que alguém lembrou de listar', async () => { + const account = await seed(); + + // Um valor diferente do semeado em cada campo editável, para o assert distinguir "gravou" de + // "por acaso já era isso". + const edited: CedenteAccount = { + ...account, + agencyDigit: '7', + nickname: 'apelido novo', + bankName: 'BANCO NOVO', + convenio: '123456', + }; + assert.ok(isOk(await store.save(edited))); + + const found = await store.findById(account.id); + assert.ok(isOk(found) && found.value !== null); + assert.deepEqual( + found.value, + edited, + 'algum campo do agregado não sobreviveu ao UPDATE — confira o `set` do onDuplicateKeyUpdate', + ); + }); + // O caso que justifica esta suíte existir — regressão do lost update em produção. // // `edit-cedente-account.ts` (e `close-cedente-account.ts`) leem a conta, montam o objeto por spread diff --git a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts index 5b662f20..af539528 100644 --- a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts +++ b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts @@ -113,4 +113,21 @@ describe('financial/adapters/persistence/mappers/cedente-account.mapper', () => assert.equal(back.ok, true); if (back.ok) assert.equal(back.value.agencyDigit, undefined); }); + + /* + * ⚠️ O VALOR RE-HIDRATADO É O APARADO — dois defeitos numa linha só, e nenhum dos dois aparece + * num teste que monte o agregado em memória (a via que o construtor já apara). + * + * 1. `text(' 5', 1)` devolve `' '`: a 058 sai em BRANCO com o dígito presente no banco. + * 2. `wantsAgencyDigitSwap` compara contra o `'5'` que o construtor e a edição gravam, então + * reenviar o MESMO dígito lê como troca e dispara a trava FR-008. + * + * Padding chega por qualquer via que não passe pelo construtor: dump restaurado, correção manual + * no banco, ETL futuro. + */ + it('#856: dígito com espaço na coluna re-hidrata APARADO, e não bruto', () => { + const back = toDomain({ ...rowOf(buildAccount()), agencyDigit: ' 5' }); + assert.equal(back.ok, true); + if (back.ok) assert.equal(back.value.agencyDigit, '5'); + }); }); diff --git a/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts b/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts index f2a0dbe3..2a00c5b6 100644 --- a/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts +++ b/tests/modules/financial/domain/cedente/remittance-eligibility.test.ts @@ -105,7 +105,7 @@ describe('Aptidão da conta-cedente à remessa — a agência (#856)', () => { } }); - // ⚠️ O CASO QUE ESTE BLOCO EXISTE PARA FIXAR. O operador digita `0288-2` na tela; se o separador + // ⚠️ O CASO QUE ESTE BLOCO EXISTE PARA FIXAR. O operador digita a agência COM o DV na tela; se o separador // sobreviver até o cadastro, `digits('01234-5', 5)` devolve `12345` — cinco dígitos, cabe, ninguém // reclama — onde o banco espera `01234`. Recusar é a única saída que não adivinha qual metade é a // agência: `12345` é `1234`+`5` ou `12345` sem DV? A #708 já decidiu que isso não se resolve por @@ -124,6 +124,47 @@ describe('Aptidão da conta-cedente à remessa — a agência (#856)', () => { assert.equal(r.error, 'cedente-agency-malformed'); }); + /* + * ⚠️ O DV DA AGÊNCIA — a recusa que impede este PR de reintroduzir o defeito que ele fecha. + * + * `alpha()` faz `.slice(0, size)`, e a 058 tem UMA posição: `text('12', 1)` descarta o segundo + * caractere e `text('-2', 1)` grava um `-` literal — os dois em silêncio, num campo de + * identificação bancária. O alfabeto vem do manual, não de palpite: dígito, `X` para resto 10, e + * `P` no Bradesco quando o resto é 1 (4008-523-0096 v16, p. 30). + */ + it('aceita os DVs que o manual admite — dígito, X e P, em qualquer caixa', () => { + for (const digit of ['0', '9', 'X', 'P', 'p', 'x']) { + assert.ok(isOk(checkCedenteAgency({ agency: '01234', agencyDigit: digit })), digit); + } + }); + + it('DV ausente é legítimo — a agência pode não ter dígito, e a 058 sai em branco', () => { + assert.ok(isOk(checkCedenteAgency({ agency: '01234' }))); + assert.ok(isOk(checkCedenteAgency({ agency: '01234', agencyDigit: '' }))); + assert.ok(isOk(checkCedenteAgency({ agency: '01234', agencyDigit: ' ' }))); + }); + + it('DV de dois caracteres é RECUSADO, em vez de gravado pela metade', () => { + const r = checkCedenteAgency({ agency: '01234', agencyDigit: '5X' }); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-agency-digit-malformed'); + }); + + it('DV fora do alfabeto do manual é recusado — inclusive o que viraria caractere literal', () => { + for (const digit of ['-', 'Z', 'ç', '/']) { + const r = checkCedenteAgency({ agency: '01234', agencyDigit: digit }); + assert.ok(isErr(r), digit); + assert.equal(r.error, 'cedente-agency-digit-malformed', digit); + } + }); + + // A ordem importa para quem lê a mensagem: sem agência não há dígito de que falar. + it('o número da agência é cobrado ANTES do dígito', () => { + const r = checkCedenteAgency({ agency: '', agencyDigit: '5X' }); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-agency-missing'); + }); + it('a readiness completa cobra as duas coisas — convênio bom não absolve agência ruim', () => { const r = checkCedenteRemittanceReadiness(withAgency('01234-5')); assert.ok(isErr(r)); From e09d5df012c7074cf70e2950dcf3e69cf8779849 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 15:17:36 -0300 Subject: [PATCH 05/13] =?UTF-8?q?fix(financial):=20a=20sequ=C3=AAncia=20de?= =?UTF-8?q?=20NSA=20passa=20a=20ser=20do=20conv=C3=AAnio,=20e=20a=20conta?= =?UTF-8?q?=20irm=C3=A3=20volta=20a=20gerar=20remessa=20(#943)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` é `` — 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 --- .../mysql/0057_lying_vindicator.sql | 41 + .../migrations/mysql/meta/0057_snapshot.json | 2822 +++++++++++++++++ .../migrations/mysql/meta/_journal.json | 7 + .../repos/cedente-account-store.drizzle.ts | 67 +- .../repos/cedente-account-store.in-memory.ts | 37 +- .../adapters/persistence/schemas/mysql.ts | 44 + .../financial/domain/cedente/nsa-sequence.ts | 79 + .../cedente-account-store.contract.ts | 177 +- .../use-cases/generate-remittance.test.ts | 57 +- .../domain/cedente/nsa-sequence.test.ts | 73 + 10 files changed, 3309 insertions(+), 95 deletions(-) create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/meta/0057_snapshot.json create mode 100644 src/modules/financial/domain/cedente/nsa-sequence.ts create mode 100644 tests/modules/financial/domain/cedente/nsa-sequence.test.ts diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql b/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql new file mode 100644 index 00000000..345bcff4 --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql @@ -0,0 +1,41 @@ +-- fin_convenio_nsa — o contador de NSA passa da CONTA para o CONVÊNIO (#943). +-- +-- ENGINE/CHARSET inseridos MANUALMENTE: o drizzle-kit 0.45.x não os emite, e sem eles a tabela +-- herda o default do servidor — que difere entre a instância de dev e a de produção. As demais +-- migrations deste módulo fazem o mesmo (ver 0000, 0001, 0003, 0048). +-- +-- ⚠️ O BACKFILL ABAIXO É A PARTE PERIGOSA, e a regra é `MAX`. Leia antes de mexer. +CREATE TABLE `fin_convenio_nsa` ( + `convenio` varchar(30) NOT NULL, + `next_nsa` int NOT NULL, + CONSTRAINT `fin_convenio_nsa_convenio` PRIMARY KEY(`convenio`), + CONSTRAINT `fin_convenio_nsa_next_nsa_chk` CHECK(`fin_convenio_nsa`.`next_nsa` >= 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +--> statement-breakpoint +-- Semeia a sequência de cada convênio a partir do MAIOR `next_nsa` entre as contas dele. +-- +-- POR QUE `MAX`, e por que qualquer outra agregação reemite número já usado: +-- +-- `next_nsa` é o PRÓXIMO a alocar, não o último emitido. Então o máximo do grupo é maior ou igual +-- a qualquer número que qualquer conta daquele convênio já tenha emitido. Começar abaixo disso +-- reemite — e reemissão é exatamente o que o Bradesco lê como RETRANSMISSÃO, que é o dano que a +-- #943 existe para impedir. +-- +-- Exemplo concreto: convênio X com conta A em 57 e conta B em 12. A emitiu 1–56; B reemitiu 1–11 +-- (o defeito). Começar em 57 não colide com nada. Começar em 12 reemitiria 12–56. +-- +-- E a colisão não seria só semântica: `fin_remittance_payables.your_number` é +-- `` com UNIQUE global, e as referências antigas continuam gravadas. +-- Um contador que retrocede bate no índice contra linhas históricas — o mesmo 503 opaco da #942, +-- por outro caminho. +-- +-- Contas `Closed` ENTRAM no GROUP BY de propósito: os números que elas gastaram existem no banco, e +-- ignorá-las reabriria faixa já usada. +-- +-- Convênio vazio fica de fora: sem convênio não há contrato a que a sequência pertença, e a conta +-- já é recusada antes do NSA por `checkCedenteConvenio` (`cedente-convenio-missing`). +INSERT INTO `fin_convenio_nsa` (`convenio`, `next_nsa`) +SELECT TRIM(`convenio`), MAX(`next_nsa`) +FROM `fin_cedente_accounts` +WHERE TRIM(`convenio`) <> '' +GROUP BY TRIM(`convenio`); diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/0057_snapshot.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0057_snapshot.json new file mode 100644 index 00000000..3fda9b7c --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0057_snapshot.json @@ -0,0 +1,2822 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "1a851617-87ed-4162-8bdf-1aa69651fdfa", + "prevId": "7cdf3f98-08ac-4558-9b91-e8f5b40c2c5c", + "tables": { + "fin_bank_statements": { + "name": "fin_bank_statements", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_format": { + "name": "file_format", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closing_balance_cents": { + "name": "closing_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_bank_statements_debit_account_ref_idx": { + "name": "fin_bank_statements_debit_account_ref_idx", + "columns": ["debit_account_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_bank_statements_id": { + "name": "fin_bank_statements_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_bank_statements_file_format_chk": { + "name": "fin_bank_statements_file_format_chk", + "value": "`fin_bank_statements`.`file_format` IN ('OFX','CSV','PDF')" + } + } + }, + "fin_categories": { + "name": "fin_categories", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group": { + "name": "group", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_id": { + "name": "cost_center_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_categories_group_name_idx": { + "name": "fin_categories_group_name_idx", + "columns": ["group", "name"], + "isUnique": false + }, + "fin_categories_active_idx": { + "name": "fin_categories_active_idx", + "columns": ["active"], + "isUnique": false + }, + "fin_categories_parent_id_idx": { + "name": "fin_categories_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "fin_categories_cost_center_id_idx": { + "name": "fin_categories_cost_center_id_idx", + "columns": ["cost_center_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_categories_id": { + "name": "fin_categories_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_categories_group_chk": { + "name": "fin_categories_group_chk", + "value": "`fin_categories`.`group` IN ('despesa','receita','ajuste')" + } + } + }, + "fin_cedente_accounts": { + "name": "fin_cedente_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_code": { + "name": "bank_code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency_digit": { + "name": "agency_digit", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_digit": { + "name": "account_digit", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "convenio": { + "name": "convenio", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_nsa": { + "name": "next_nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type_label": { + "name": "type_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_date": { + "name": "opening_balance_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_cedente_accounts_natural_key_uq": { + "name": "fin_cedente_accounts_natural_key_uq", + "columns": ["bank_code", "agency", "account_number", "account_digit"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cedente_accounts_id": { + "name": "fin_cedente_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_cedente_accounts_status_chk": { + "name": "fin_cedente_accounts_status_chk", + "value": "`fin_cedente_accounts`.`status` IN ('Active','Closed')" + }, + "fin_cedente_accounts_next_nsa_chk": { + "name": "fin_cedente_accounts_next_nsa_chk", + "value": "`fin_cedente_accounts`.`next_nsa` >= 1" + }, + "fin_cedente_accounts_type_chk": { + "name": "fin_cedente_accounts_type_chk", + "value": "`fin_cedente_accounts`.`type` IS NULL OR `fin_cedente_accounts`.`type` IN ('corrente','poupanca','investimento','cartao','outro')" + } + } + }, + "fin_convenio_nsa": { + "name": "fin_convenio_nsa", + "columns": { + "convenio": { + "name": "convenio", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_nsa": { + "name": "next_nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_convenio_nsa_convenio": { + "name": "fin_convenio_nsa_convenio", + "columns": ["convenio"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_convenio_nsa_next_nsa_chk": { + "name": "fin_convenio_nsa_next_nsa_chk", + "value": "`fin_convenio_nsa`.`next_nsa` >= 1" + } + } + }, + "fin_cost_centers": { + "name": "fin_cost_centers", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "fin_cost_centers_code_idx": { + "name": "fin_cost_centers_code_idx", + "columns": ["code"], + "isUnique": false + }, + "fin_cost_centers_active_idx": { + "name": "fin_cost_centers_active_idx", + "columns": ["active"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cost_centers_id": { + "name": "fin_cost_centers_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_document_timeline": { + "name": "fin_document_timeline", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_ref": { + "name": "actor_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tl_doc_time": { + "name": "idx_fin_tl_doc_time", + "columns": ["document_id", "occurred_at"], + "isUnique": false + }, + "idx_fin_tl_target": { + "name": "idx_fin_tl_target", + "columns": ["target_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_document_timeline_document_id_fk": { + "name": "fin_document_timeline_document_id_fk", + "tableFrom": "fin_document_timeline", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_document_timeline_id": { + "name": "fin_document_timeline_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "ck_fin_tl_target_kind": { + "name": "ck_fin_tl_target_kind", + "value": "`fin_document_timeline`.`target_kind` IN ('Document','Payable')" + }, + "ck_fin_tl_event_type": { + "name": "ck_fin_tl_event_type", + "value": "`fin_document_timeline`.`event_type` IN ('DocumentSaved','PayableApproved','ApprovalUndone','DocumentDraftSaved','PayableManuallyPaid')" + } + } + }, + "fin_documents": { + "name": "fin_documents", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "series": { + "name": "series", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payee_kind": { + "name": "payee_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gross_value": { + "name": "gross_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_discounts": { + "name": "source_discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "discounts": { + "name": "discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "penalty": { + "name": "penalty", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "interest": { + "name": "interest", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "net_value": { + "name": "net_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key": { + "name": "access_key", + "type": "varchar(44)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competencia": { + "name": "competencia", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_bucket": { + "name": "source_file_bucket", + "type": "varchar(63)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_key": { + "name": "source_file_key", + "type": "varchar(1024) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_hash_sha256": { + "name": "source_file_hash_sha256", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_size_bytes": { + "name": "source_file_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_mime": { + "name": "source_file_mime", + "type": "varchar(127)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_by_ocr": { + "name": "read_by_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ocr_original_value": { + "name": "ocr_original_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "divergence_detected": { + "name": "divergence_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approver_ref": { + "name": "approver_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_documents_supplier_ref_idx": { + "name": "fin_documents_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_documents_status_idx": { + "name": "fin_documents_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_documents_due_date_idx": { + "name": "fin_documents_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_documents_issue_date_idx": { + "name": "fin_documents_issue_date_idx", + "columns": ["issue_date"], + "isUnique": false + }, + "fin_documents_doc_number_idx": { + "name": "fin_documents_doc_number_idx", + "columns": ["document_number"], + "isUnique": false + }, + "fin_documents_legacy_id_uq": { + "name": "fin_documents_legacy_id_uq", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_documents_id": { + "name": "fin_documents_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_documents_type_chk": { + "name": "fin_documents_type_chk", + "value": "`fin_documents`.`type` IS NULL OR `fin_documents`.`type` IN ('NFS-e','DANFE','RPA','Fatura','Boleto','Recibo','Imposto')" + }, + "fin_documents_payment_method_chk": { + "name": "fin_documents_payment_method_chk", + "value": "`fin_documents`.`payment_method` IS NULL OR `fin_documents`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_documents_payee_kind_chk": { + "name": "fin_documents_payee_kind_chk", + "value": "`fin_documents`.`payee_kind` IS NULL OR `fin_documents`.`payee_kind` IN ('supplier','financier','act','collaborator')" + }, + "fin_documents_status_chk": { + "name": "fin_documents_status_chk", + "value": "`fin_documents`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_documents_gross_value_chk": { + "name": "fin_documents_gross_value_chk", + "value": "`fin_documents`.`gross_value` IS NULL OR `fin_documents`.`gross_value` >= 0" + }, + "fin_documents_net_value_chk": { + "name": "fin_documents_net_value_chk", + "value": "`fin_documents`.`net_value` IS NULL OR `fin_documents`.`net_value` >= 0" + }, + "fin_documents_source_discounts_chk": { + "name": "fin_documents_source_discounts_chk", + "value": "`fin_documents`.`source_discounts` >= 0" + }, + "fin_documents_discounts_chk": { + "name": "fin_documents_discounts_chk", + "value": "`fin_documents`.`discounts` >= 0" + }, + "fin_documents_penalty_chk": { + "name": "fin_documents_penalty_chk", + "value": "`fin_documents`.`penalty` >= 0" + }, + "fin_documents_interest_chk": { + "name": "fin_documents_interest_chk", + "value": "`fin_documents`.`interest` >= 0" + }, + "fin_documents_version_chk": { + "name": "fin_documents_version_chk", + "value": "`fin_documents`.`version` >= 0" + }, + "fin_documents_source_file_all_or_none_chk": { + "name": "fin_documents_source_file_all_or_none_chk", + "value": "(`fin_documents`.`source_file_bucket` IS NULL AND `fin_documents`.`source_file_key` IS NULL AND `fin_documents`.`source_file_hash_sha256` IS NULL AND `fin_documents`.`source_file_size_bytes` IS NULL AND `fin_documents`.`source_file_mime` IS NULL) OR (`fin_documents`.`source_file_bucket` IS NOT NULL AND `fin_documents`.`source_file_key` IS NOT NULL AND `fin_documents`.`source_file_hash_sha256` IS NOT NULL AND `fin_documents`.`source_file_size_bytes` IS NOT NULL AND `fin_documents`.`source_file_mime` IS NOT NULL)" + }, + "fin_documents_source_file_size_bytes_chk": { + "name": "fin_documents_source_file_size_bytes_chk", + "value": "`fin_documents`.`source_file_size_bytes` IS NULL OR `fin_documents`.`source_file_size_bytes` > 0" + } + } + }, + "fin_expected_counterpart": { + "name": "fin_expected_counterpart", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_account_ref": { + "name": "origin_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_reconciliation_ref": { + "name": "origin_reconciliation_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_transaction_ref": { + "name": "origin_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_date": { + "name": "expected_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matched_transaction_ref": { + "name": "matched_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_expected_counterpart_destination_status_idx": { + "name": "fin_expected_counterpart_destination_status_idx", + "columns": ["destination_account_ref", "status"], + "isUnique": false + }, + "fin_expected_counterpart_origin_reconciliation_idx": { + "name": "fin_expected_counterpart_origin_reconciliation_idx", + "columns": ["origin_reconciliation_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_expected_counterpart_id": { + "name": "fin_expected_counterpart_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_expected_counterpart_type_chk": { + "name": "fin_expected_counterpart_type_chk", + "value": "`fin_expected_counterpart`.`type` IN ('Transfer','Investment','Redemption')" + }, + "fin_expected_counterpart_movement_chk": { + "name": "fin_expected_counterpart_movement_chk", + "value": "`fin_expected_counterpart`.`movement` IN ('Debit','Credit')" + }, + "fin_expected_counterpart_status_chk": { + "name": "fin_expected_counterpart_status_chk", + "value": "`fin_expected_counterpart`.`status` IN ('Pending','Matched','Discarded')" + }, + "fin_expected_counterpart_value_chk": { + "name": "fin_expected_counterpart_value_chk", + "value": "`fin_expected_counterpart`.`value_cents` > 0" + } + } + }, + "fin_manual_entries": { + "name": "fin_manual_entries", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_type": { + "name": "document_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_value_cents": { + "name": "document_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_manual_entries_reconciliation_id_idx": { + "name": "fin_manual_entries_reconciliation_id_idx", + "columns": ["reconciliation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_manual_entries_reconciliation_id_fk": { + "name": "fin_manual_entries_reconciliation_id_fk", + "tableFrom": "fin_manual_entries", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_manual_entries_id": { + "name": "fin_manual_entries_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_manual_entries_type_chk": { + "name": "fin_manual_entries_type_chk", + "value": "`fin_manual_entries`.`type` IN ('Payment','Receipt','Transfer','FeePenaltyInterest','Investment','Redemption')" + }, + "fin_manual_entries_value_chk": { + "name": "fin_manual_entries_value_chk", + "value": "`fin_manual_entries`.`value_cents` > 0" + } + } + }, + "fin_outbox": { + "name": "fin_outbox", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_processed_at_occurred_at_idx": { + "name": "fin_outbox_processed_at_occurred_at_idx", + "columns": ["processed_at", "occurred_at"], + "isUnique": false + }, + "fin_outbox_aggregate_id_idx": { + "name": "fin_outbox_aggregate_id_idx", + "columns": ["aggregate_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_event_id": { + "name": "fin_outbox_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_attempts_nonneg_chk": { + "name": "fin_outbox_attempts_nonneg_chk", + "value": "`fin_outbox`.`attempts` >= 0" + }, + "fin_outbox_event_type_nonempty_chk": { + "name": "fin_outbox_event_type_nonempty_chk", + "value": "CHAR_LENGTH(`fin_outbox`.`event_type`) > 0" + }, + "fin_outbox_aggregate_type_chk": { + "name": "fin_outbox_aggregate_type_chk", + "value": "`fin_outbox`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_outbox_dead_letter": { + "name": "fin_outbox_dead_letter", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_dl_failed_at_idx": { + "name": "fin_outbox_dl_failed_at_idx", + "columns": ["failed_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_dead_letter_consumer_id_event_id_pk": { + "name": "fin_outbox_dead_letter_consumer_id_event_id_pk", + "columns": ["consumer_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_dl_attempts_nonneg_chk": { + "name": "fin_outbox_dl_attempts_nonneg_chk", + "value": "`fin_outbox_dead_letter`.`attempts` >= 0" + }, + "fin_outbox_dl_aggregate_type_chk": { + "name": "fin_outbox_dl_aggregate_type_chk", + "value": "`fin_outbox_dead_letter`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_payable_view": { + "name": "fin_payable_view", + "columns": { + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01 00:00:00.000'" + } + }, + "indexes": { + "fin_payable_view_status_idx": { + "name": "fin_payable_view_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_payable_view_cost_center_ref_idx": { + "name": "fin_payable_view_cost_center_ref_idx", + "columns": ["cost_center_ref"], + "isUnique": false + }, + "fin_payable_view_category_ref_idx": { + "name": "fin_payable_view_category_ref_idx", + "columns": ["category_ref"], + "isUnique": false + }, + "fin_payable_view_budget_plan_ref_idx": { + "name": "fin_payable_view_budget_plan_ref_idx", + "columns": ["budget_plan_ref"], + "isUnique": false + }, + "fin_payable_view_subcategory_ref_idx": { + "name": "fin_payable_view_subcategory_ref_idx", + "columns": ["subcategory_ref"], + "isUnique": false + }, + "fin_payable_view_program_ref_idx": { + "name": "fin_payable_view_program_ref_idx", + "columns": ["program_ref"], + "isUnique": false + }, + "fin_payable_view_supplier_ref_idx": { + "name": "fin_payable_view_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_payable_view_due_date_idx": { + "name": "fin_payable_view_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_payable_view_paid_at_idx": { + "name": "fin_payable_view_paid_at_idx", + "columns": ["paid_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_payable_view_payable_id": { + "name": "fin_payable_view_payable_id", + "columns": ["payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payable_view_kind_chk": { + "name": "fin_payable_view_kind_chk", + "value": "`fin_payable_view`.`kind` IN ('Parent','Child')" + }, + "fin_payable_view_status_chk": { + "name": "fin_payable_view_status_chk", + "value": "`fin_payable_view`.`status` IN ('Open','Approved','Transmitted','Paid','Cancelled')" + }, + "fin_payable_view_retention_type_chk": { + "name": "fin_payable_view_retention_type_chk", + "value": "`fin_payable_view`.`retention_type` IS NULL OR `fin_payable_view`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + } + } + }, + "fin_payables": { + "name": "fin_payables", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_payables_document_id_idx": { + "name": "fin_payables_document_id_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_payables_status_idx": { + "name": "fin_payables_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_payables_document_id_fk": { + "name": "fin_payables_document_id_fk", + "tableFrom": "fin_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_payables_id": { + "name": "fin_payables_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payables_kind_chk": { + "name": "fin_payables_kind_chk", + "value": "`fin_payables`.`kind` IN ('Parent','Child')" + }, + "fin_payables_retention_type_chk": { + "name": "fin_payables_retention_type_chk", + "value": "`fin_payables`.`retention_type` IS NULL OR `fin_payables`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_payables_status_chk": { + "name": "fin_payables_status_chk", + "value": "`fin_payables`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_payables_payment_method_chk": { + "name": "fin_payables_payment_method_chk", + "value": "`fin_payables`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_payables_value_chk": { + "name": "fin_payables_value_chk", + "value": "`fin_payables`.`value` >= 0" + }, + "fin_payables_child_retention_chk": { + "name": "fin_payables_child_retention_chk", + "value": "(`fin_payables`.`kind` = 'Child') = (`fin_payables`.`retention_type` IS NOT NULL)" + }, + "fin_payables_paid_at_chk": { + "name": "fin_payables_paid_at_chk", + "value": "`fin_payables`.`status` <> 'Paid' OR `fin_payables`.`paid_at` IS NOT NULL" + } + } + }, + "fin_reconciliation_items": { + "name": "fin_reconciliation_items", + "columns": { + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_value_cents": { + "name": "reconciled_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_items_payable_id_idx": { + "name": "fin_reconciliation_items_payable_id_idx", + "columns": ["payable_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_reconciliation_items_reconciliation_id_fk": { + "name": "fin_reconciliation_items_reconciliation_id_fk", + "tableFrom": "fin_reconciliation_items", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_reconciliation_items_reconciliation_id_payable_id_pk": { + "name": "fin_reconciliation_items_reconciliation_id_payable_id_pk", + "columns": ["reconciliation_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_reconciliation_periods": { + "name": "fin_reconciliation_periods", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_by": { + "name": "closed_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_periods_account_range_uq": { + "name": "fin_reconciliation_periods_account_range_uq", + "columns": ["debit_account_ref", "period_start", "period_end"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliation_periods_id": { + "name": "fin_reconciliation_periods_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliation_periods_status_chk": { + "name": "fin_reconciliation_periods_status_chk", + "value": "`fin_reconciliation_periods`.`status` IN ('Open','Closed')" + } + } + }, + "fin_reconciliations": { + "name": "fin_reconciliations", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difference_value_cents": { + "name": "difference_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "difference_treatment": { + "name": "difference_treatment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_by": { + "name": "reconciled_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undone_by": { + "name": "undone_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_reason": { + "name": "undo_reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliations_transaction_id_idx": { + "name": "fin_reconciliations_transaction_id_idx", + "columns": ["transaction_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliations_id": { + "name": "fin_reconciliations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliations_type_chk": { + "name": "fin_reconciliations_type_chk", + "value": "`fin_reconciliations`.`type` IN ('Individual','Multiple','Partial','ManualEntry')" + }, + "fin_reconciliations_status_chk": { + "name": "fin_reconciliations_status_chk", + "value": "`fin_reconciliations`.`status` IN ('Active','Undone')" + }, + "fin_reconciliations_difference_chk": { + "name": "fin_reconciliations_difference_chk", + "value": "(`fin_reconciliations`.`difference_value_cents` IS NULL AND `fin_reconciliations`.`difference_treatment` IS NULL) OR (`fin_reconciliations`.`difference_value_cents` IS NOT NULL AND `fin_reconciliations`.`difference_treatment` IN ('Interest','Penalty','Discount','Fee','Partial'))" + } + } + }, + "fin_registered_taxes": { + "name": "fin_registered_taxes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_registered_taxes_document_id_idx": { + "name": "fin_registered_taxes_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_registered_taxes_document_id_fk": { + "name": "fin_registered_taxes_document_id_fk", + "tableFrom": "fin_registered_taxes", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_registered_taxes_id": { + "name": "fin_registered_taxes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_registered_taxes_type_chk": { + "name": "fin_registered_taxes_type_chk", + "value": "`fin_registered_taxes`.`type` IN ('ICMS','IPI','PIS','COFINS','CBS','IBS_Municipal','IBS_Estadual')" + }, + "fin_registered_taxes_base_chk": { + "name": "fin_registered_taxes_base_chk", + "value": "`fin_registered_taxes`.`base` >= 0" + }, + "fin_registered_taxes_rate_bps_chk": { + "name": "fin_registered_taxes_rate_bps_chk", + "value": "`fin_registered_taxes`.`rate_bps` >= 0" + }, + "fin_registered_taxes_value_chk": { + "name": "fin_registered_taxes_value_chk", + "value": "`fin_registered_taxes`.`value` >= 0" + } + } + }, + "fin_rejected_suggestions": { + "name": "fin_rejected_suggestions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_rejected_suggestions_tx_payable_uq": { + "name": "fin_rejected_suggestions_tx_payable_uq", + "columns": ["transaction_id", "payable_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_rejected_suggestions_id": { + "name": "fin_rejected_suggestions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_documents": { + "name": "fin_remittance_documents", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_documents_document_idx": { + "name": "fin_remittance_documents_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_documents_your_number_uk": { + "name": "fin_remittance_documents_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittance_documents_remittance_id_document_id_pk": { + "name": "fin_remittance_documents_remittance_id_document_id_pk", + "columns": ["remittance_id", "document_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_payables": { + "name": "fin_remittance_payables", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_payables_payable_idx": { + "name": "fin_remittance_payables_payable_idx", + "columns": ["payable_id"], + "isUnique": false + }, + "fin_remittance_payables_document_idx": { + "name": "fin_remittance_payables_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_payables_your_number_uk": { + "name": "fin_remittance_payables_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": { + "fin_remittance_payables_remittance_id_fk": { + "name": "fin_remittance_payables_remittance_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_remittances", + "columnsFrom": ["remittance_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_payable_id_fk": { + "name": "fin_remittance_payables_payable_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_payables", + "columnsFrom": ["payable_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_document_id_fk": { + "name": "fin_remittance_payables_document_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_remittance_payables_remittance_id_payable_id_pk": { + "name": "fin_remittance_payables_remittance_id_payable_id_pk", + "columns": ["remittance_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittances": { + "name": "fin_remittances", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cedente_account_id": { + "name": "cedente_account_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nsa": { + "name": "nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_remittances_account_nsa_uq": { + "name": "fin_remittances_account_nsa_uq", + "columns": ["cedente_account_id", "nsa"], + "isUnique": true + }, + "fin_remittances_file_name_uq": { + "name": "fin_remittances_file_name_uq", + "columns": ["file_name"], + "isUnique": true + }, + "fin_remittances_status_idx": { + "name": "fin_remittances_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittances_id": { + "name": "fin_remittances_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_remittances_status_chk": { + "name": "fin_remittances_status_chk", + "value": "`fin_remittances`.`status` IN ('Queued','Transmitted','Failed','Discarded')" + }, + "fin_remittances_nsa_range_chk": { + "name": "fin_remittances_nsa_range_chk", + "value": "`fin_remittances`.`nsa` >= 1 AND `fin_remittances`.`nsa` <= 999999" + } + } + }, + "fin_retentions": { + "name": "fin_retentions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_retentions_document_id_idx": { + "name": "fin_retentions_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_retentions_document_id_fk": { + "name": "fin_retentions_document_id_fk", + "tableFrom": "fin_retentions", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_retentions_id": { + "name": "fin_retentions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_retentions_type_chk": { + "name": "fin_retentions_type_chk", + "value": "`fin_retentions`.`type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_retentions_base_chk": { + "name": "fin_retentions_base_chk", + "value": "`fin_retentions`.`base` >= 0" + }, + "fin_retentions_rate_bps_chk": { + "name": "fin_retentions_rate_bps_chk", + "value": "`fin_retentions`.`rate_bps` >= 0" + }, + "fin_retentions_value_chk": { + "name": "fin_retentions_value_chk", + "value": "`fin_retentions`.`value` >= 0" + } + } + }, + "fin_statement_transactions": { + "name": "fin_statement_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statement_id": { + "name": "statement_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fitid": { + "name": "fitid", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_type": { + "name": "entry_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payee_name": { + "name": "payee_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memo": { + "name": "memo", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balance_after_cents": { + "name": "balance_after_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_status": { + "name": "reconciliation_status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_statement_transactions_account_fitid_uq": { + "name": "fin_statement_transactions_account_fitid_uq", + "columns": ["debit_account_ref", "fitid"], + "isUnique": true + }, + "fin_statement_transactions_statement_id_idx": { + "name": "fin_statement_transactions_statement_id_idx", + "columns": ["statement_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_statement_transactions_statement_id_fk": { + "name": "fin_statement_transactions_statement_id_fk", + "tableFrom": "fin_statement_transactions", + "tableTo": "fin_bank_statements", + "columnsFrom": ["statement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_statement_transactions_id": { + "name": "fin_statement_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_statement_transactions_movement_chk": { + "name": "fin_statement_transactions_movement_chk", + "value": "`fin_statement_transactions`.`movement` IN ('Debit','Credit')" + }, + "fin_statement_transactions_entry_type_chk": { + "name": "fin_statement_transactions_entry_type_chk", + "value": "`fin_statement_transactions`.`entry_type` IN ('PIX','TED','DOC','Fee','Boleto','DARF','Investment','Redemption','Transfer','Other')" + }, + "fin_statement_transactions_recon_status_chk": { + "name": "fin_statement_transactions_recon_status_chk", + "value": "`fin_statement_transactions`.`reconciliation_status` IN ('Pending','Reconciled','ManualEntry')" + } + } + }, + "fin_supplier_view": { + "name": "fin_supplier_view", + "columns": { + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_supplier_view_supplier_ref": { + "name": "fin_supplier_view_supplier_ref", + "columns": ["supplier_ref"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_timeline_field_changes": { + "name": "fin_timeline_field_changes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timeline_entry_id": { + "name": "timeline_entry_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_value": { + "name": "before_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after_value": { + "name": "after_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tlfc_entry": { + "name": "idx_fin_tlfc_entry", + "columns": ["timeline_entry_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_timeline_field_changes_entry_id_fk": { + "name": "fin_timeline_field_changes_entry_id_fk", + "tableFrom": "fin_timeline_field_changes", + "tableTo": "fin_document_timeline", + "columnsFrom": ["timeline_entry_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_timeline_field_changes_id": { + "name": "fin_timeline_field_changes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_van_return_quarantine": { + "name": "fin_van_return_quarantine", + "columns": { + "object_key": { + "name": "object_key", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_sha256": { + "name": "observed_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_sha256": { + "name": "expected_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_van_return_quarantine_released_idx": { + "name": "fin_van_return_quarantine_released_idx", + "columns": ["released_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_van_return_quarantine_object_key": { + "name": "fin_van_return_quarantine_object_key", + "columns": ["object_key"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_van_return_quarantine_reason_chk": { + "name": "fin_van_return_quarantine_reason_chk", + "value": "`fin_van_return_quarantine`.`reason` IN ('missing-provenance','hash-mismatch','origin-not-logged')" + } + } + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json index 3db70448..a733f757 100644 --- a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json @@ -400,6 +400,13 @@ "when": 1788702432348, "tag": "0056_daily_firebrand", "breakpoints": true + }, + { + "idx": 57, + "version": "5", + "when": 1788718100073, + "tag": "0057_lying_vindicator", + "breakpoints": true } ] } diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index 434bd9cf..51f043fd 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -15,10 +15,11 @@ import type { CedenteAccountStoreError, NsaAllocationError, } from '#src/modules/financial/application/ports/cedente-account-store.ts'; -import { allocateNsa as allocateNsaOf } from '#src/modules/financial/domain/cedente/cedente-account.ts'; +import { isActive } from '#src/modules/financial/domain/cedente/cedente-account.ts'; +import * as NsaSequence from '#src/modules/financial/domain/cedente/nsa-sequence.ts'; import type { Nsa } from '#src/modules/financial/domain/cedente/nsa.ts'; import type { FinancialMysqlHandle } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; -import { finCedenteAccounts } from '../schemas/mysql.ts'; +import { finCedenteAccounts, finConvenioNsa } from '../schemas/mysql.ts'; import { toRow, toDomain } from '../mappers/cedente-account.mapper.ts'; const logStore = (op: string, cause: unknown): void => { @@ -181,32 +182,74 @@ export const createDrizzleCedenteAccountStore = ( // por isso que a operação não é composição de `findById` + `save` no use case. // // A decisão de negócio continua no domínio: este método só empresta a serialização. + // ⚠️ O LOCK É DA LINHA DO CONVÊNIO, não da conta (#943). A conta ainda diz QUAL convênio — por + // isso o port continua recebendo `cedenteAccountId` —, mas quem tem o contador é o contrato + // multipag: várias contas de pagamento sob o mesmo convênio compartilham uma sequência só. + // + // Enquanto o lock era da conta, duas contas irmãs não se serializavam entre si — cada uma + // caminhava no próprio contador, ambas partindo de 1. O resultado não era uma corrida perdida: + // era o número repetido POR DESENHO, que o banco lê como retransmissão e que o UNIQUE de + // `your_number` recusava com 503 opaco (#942). + // + // A ORDEM DENTRO DA TRANSAÇÃO É REGRA, não arrumação: lê a conta e confere que ela paga ANTES + // de tocar a sequência. Invertida, uma conta encerrada queimaria um número do convênio inteiro — + // e o número não volta. allocateNsa: async (id: CedenteAccountId): Promise> => { try { return await db.transaction(async (tx) => { - const rows = await tx + // 1. A CONTA — sem lock: ela não é mais o recurso disputado, só a origem do convênio e a + // dona da guarda de "pode pagar?". + const accountRows = await tx .select() .from(finCedenteAccounts) .where(eq(finCedenteAccounts.id, id)) - .for('update') .limit(1); - const row = rows[0]; - if (row === undefined) return err('cedente-account-not-found'); + const accountRow = accountRows[0]; + if (accountRow === undefined) return err('cedente-account-not-found'); - const account = toDomain(row); + const account = toDomain(accountRow); if (!account.ok) { logStore('allocateNsa/toDomain', account.error); return err('cedente-account-store-unavailable'); } - const allocation = allocateNsaOf(account.value); - if (!allocation.ok) return err(allocation.error); + if (!isActive(account.value)) return err('cedente-account-not-active'); + + // Sem convênio não há sequência a que pertencer (CA6). A recusa nomeada vive em + // `checkCedenteConvenio`, ANTES do NSA; chegar aqui com o campo vazio significa que um + // chamador novo pulou a elegibilidade — e a PK da sequência não aceita string vazia como + // identidade de contrato. + const convenio = account.value.convenio.trim(); + if (convenio === '') return err('cedente-account-not-found'); + + // 2. A SEQUÊNCIA — COM lock. É aqui que as contas irmãs se serializam. + const sequenceRows = await tx + .select() + .from(finConvenioNsa) + .where(eq(finConvenioNsa.convenio, convenio)) + .for('update') + .limit(1); + + const sequenceRow = sequenceRows[0]; + // Convênio sem linha é convênio genuinamente novo — nasce em `Nsa.MIN`. O backfill da + // migration já criou linha para todo convênio que JÁ emitiu, partindo do `MAX`, então + // chegar aqui sem linha não pode reemitir faixa usada. + const sequence = + sequenceRow === undefined + ? NsaSequence.start(convenio) + : { convenio: sequenceRow.convenio, nextNsa: sequenceRow.nextNsa }; + + const allocation = NsaSequence.allocate(sequence); + if (!allocation.ok) return err('nsa-exhausted'); + // `ON DUPLICATE KEY UPDATE` cobre o convênio novo e o existente num caminho só. Duas + // gerações concorrentes do MESMO convênio novo não passam as duas: a segunda espera no + // lock da PK e enxerga a linha da primeira. await tx - .update(finCedenteAccounts) - .set({ nextNsa: allocation.value.account.nextNsa }) - .where(eq(finCedenteAccounts.id, id)); + .insert(finConvenioNsa) + .values({ convenio, nextNsa: allocation.value.sequence.nextNsa }) + .onDuplicateKeyUpdate({ set: { nextNsa: allocation.value.sequence.nextNsa } }); return ok(allocation.value.nsa); }); diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts index a5b18410..c688e068 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts @@ -7,12 +7,27 @@ import type { CedenteAccountStoreError, NsaAllocationError, } from '../../../application/ports/cedente-account-store.ts'; -import { allocateNsa } from '../../../domain/cedente/cedente-account.ts'; +import { isActive } from '../../../domain/cedente/cedente-account.ts'; +import * as NsaSequence from '../../../domain/cedente/nsa-sequence.ts'; import type { Nsa } from '../../../domain/cedente/nsa.ts'; // Adapter in-memory do CedenteAccountStore (testes / boot sem DB). -export const createInMemoryCedenteAccountStore = (): CedenteAccountStore => { +// +// `sequences` semeia o contador de um convênio — o equivalente ao que o backfill da migration faz em +// `fin_convenio_nsa`. Existe porque, com o dono da sequência fora da conta (#943), criar a conta +// deixou de ser caminho para posicionar o contador: um caso que precise partir de um número alto +// (faixa esgotada, por exemplo) não tem outro jeito de chegar lá senão emitir de um em um. +export const createInMemoryCedenteAccountStore = ( + options: Readonly<{ sequences?: Readonly> }> = {}, +): CedenteAccountStore => { const accounts = new Map(); + // A sequência de NSA por CONVÊNIO (#943) — espelha `fin_convenio_nsa`, e não a coluna da conta. + const sequences = new Map( + Object.entries(options.sequences ?? {}).map(([convenio, nextNsa]) => [ + convenio, + { convenio, nextNsa }, + ]), + ); return { findById: async ( @@ -62,14 +77,26 @@ export const createInMemoryCedenteAccountStore = (): CedenteAccountStore => { // leitura e a escrita. É o comportamento OBSERVÁVEL do adapter real — mas a garantia dele vem // do lock de linha do InnoDB, e só o teste contra MySQL a prova. Um fake verde aqui não diz // nada sobre concorrência real. + // + // ⚠️ O CONTADOR É DO CONVÊNIO (#943), e o fake tem de espelhar isso ou os testes ficam verdes + // descrevendo o defeito. Enquanto ele guardava o número na conta, duas contas irmãs alocavam + // `1` cada uma aqui dentro — exatamente o que quebrou em produção, e nada nesta suíte apontava. + // A guarda de conta ativa vem ANTES de consumir o número, como no adapter real: conta encerrada + // não queima NSA do convênio. allocateNsa: async (id: CedenteAccountId): Promise> => { const account = accounts.get(id); if (account === undefined) return Promise.resolve(err('cedente-account-not-found')); + if (!isActive(account)) return Promise.resolve(err('cedente-account-not-active')); + + const convenio = account.convenio.trim(); + if (convenio === '') return Promise.resolve(err('cedente-account-not-found')); - const allocation = allocateNsa(account); - if (!allocation.ok) return Promise.resolve(err(allocation.error)); + const allocation = NsaSequence.allocate( + sequences.get(convenio) ?? NsaSequence.start(convenio), + ); + if (!allocation.ok) return Promise.resolve(err('nsa-exhausted')); - accounts.set(id, allocation.value.account); + sequences.set(convenio, allocation.value.sequence); return Promise.resolve(ok(allocation.value.nsa)); }, }; diff --git a/src/modules/financial/adapters/persistence/schemas/mysql.ts b/src/modules/financial/adapters/persistence/schemas/mysql.ts index 2375f65d..509ad003 100644 --- a/src/modules/financial/adapters/persistence/schemas/mysql.ts +++ b/src/modules/financial/adapters/persistence/schemas/mysql.ts @@ -693,6 +693,50 @@ export const finCedenteAccounts = mysqlTable( ], ); +// ─── fin_convenio_nsa ───────────────────────────────────────────────────────── +// +// O CONTADOR DE NSA, e ele é do CONVÊNIO — não da conta-cedente (#943). +// +// O mesmo contrato multipag vale para VÁRIAS contas de pagamento (confirmado com o gerente do +// Bradesco em 02/09/2026). Enquanto o contador viveu em `fin_cedente_accounts.next_nsa`, cada conta +// nova nascia em 1 e duas contas irmãs emitiam o mesmo número sob o mesmo contrato — que o banco lê +// como retransmissão, não como remessa nova. +// +// ⚠️ E o defeito quebrava ANTES de chegar ao banco: `fin_remittance_payables.your_number` é +// ``, com UNIQUE global e SEM componente de tempo. Duas contas do mesmo +// convênio, ambas em `000001`, geravam a MESMA referência e o segundo INSERT era recusado — 503 +// opaco, conta sem conseguir gerar remessa (#942, medido em produção). +// +// A PK é o CONVÊNIO, e não um id sintético, por três razões que se reforçam: o convênio É a +// identidade do contrato junto ao banco; `fin_cedente_accounts` não tem índice em `convenio`, e a PK +// daqui resolve a busca; e o lock de linha do `SELECT … FOR UPDATE` passa a serializar exatamente +// entre as contas irmãs, que é onde o defeito mora. +// +// ⚠️ O convênio é TEXTO DIGITADO no cadastro de cada conta, não entidade própria — não há FK a +// declarar, e nada impede (nem deve impedir) duas contas carregarem o mesmo número: é o desenho +// pretendido. A consequência a conhecer: convênio digitado errado cria uma sequência nova em 1, e o +// sintoma que aparece primeiro é o NSA reiniciando, não o cadastro errado. +// +// ⚠️ CHARSET/COLLATE — inserir manualmente na migration gerada (limitação Drizzle 0.45.x): +// ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci. +export const finConvenioNsa = mysqlTable( + 'fin_convenio_nsa', + { + convenio: varchar('convenio', { length: 30 }).primaryKey().notNull(), + // O PRÓXIMO a alocar, não o último emitido. A distinção é o que torna o backfill correto: + // `MAX(next_nsa)` das contas do convênio é ≥ qualquer número já emitido por qualquer uma delas. + nextNsa: int('next_nsa').notNull(), + }, + (t) => [ + // Espelha `fin_cedente_accounts_next_nsa_chk`: o campo tem seis dígitos no header (158-163), e o + // teto vive no VO `Nsa`. O CHECK é a rede no banco para quem escrever fora do domínio. + check('fin_convenio_nsa_next_nsa_chk', sql`${t.nextNsa} >= 1`), + ], +); + +export type FinConvenioNsaRow = typeof finConvenioNsa.$inferSelect; +export type NewFinConvenioNsaRow = typeof finConvenioNsa.$inferInsert; + // ─── fin_bank_statements ────────────────────────────────────────────────────── // // Raiz do agregado BankStatement (US1 conciliação): extrato importado (OFX/CSV). period_* e file_* diff --git a/src/modules/financial/domain/cedente/nsa-sequence.ts b/src/modules/financial/domain/cedente/nsa-sequence.ts new file mode 100644 index 00000000..42147ead --- /dev/null +++ b/src/modules/financial/domain/cedente/nsa-sequence.ts @@ -0,0 +1,79 @@ +import { type Result, ok, err } from '../../../../shared/primitives/result.ts'; +import { immutable } from '../../../../shared/primitives/immutable.ts'; +import * as Nsa from './nsa.ts'; + +// A SEQUÊNCIA DE NSA — e ela pertence ao CONVÊNIO, não à conta-cedente (#943). +// +// ## O que estava errado, e como o defeito se manifestava +// +// O contador vivia em `fin_cedente_accounts.next_nsa`, uma linha por conta. Mas o mesmo contrato +// multipag vale para VÁRIAS contas de pagamento — confirmado pela P.O. com o gerente do Bradesco em +// 02/09/2026 —, e cada conta nova nascia em `Nsa.MIN`. Duas contas sob o mesmo convênio caminhavam +// em contadores independentes, e o número 1 existia nas duas, sob o mesmo contrato. +// +// Para o banco isso não é remessa nova: é **o mesmo arquivo transmitido duas vezes**. +// +// ⚠️ E o dano chegou ANTES do banco. A referência do G064 é +// `referenceFor(convênio, nsa, posição)` — três componentes, **nenhum deles tempo** — e +// `fin_remittance_payables.your_number` tem 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 via `An internal error occurred` (503) e a conta simplesmente não gerava +// remessa — o bloqueio medido em produção na #942. +// +// 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 que o sintoma apareceu na tabela de vínculo, e não na de remessas. +// +// ## Por que a guarda de conta ativa NÃO mora aqui +// +// "Esta conta pode pagar?" é pergunta da CONTA (`isActive`); "qual o próximo número deste contrato?" +// é pergunta da SEQUÊNCIA. O adapter compõe as duas **nesta ordem** — conta primeiro, número depois. +// Invertida, uma conta encerrada queimaria um NSA do convênio inteiro, que é o oposto do que esta +// sequência existe para proteger: o número não volta. + +export type ConvenioNsaSequence = Readonly<{ + convenio: string; + nextNsa: number; +}>; + +export type NsaSequenceError = 'nsa-exhausted'; + +export type NsaAllocation = Readonly<{ + nsa: Nsa.Nsa; + sequence: ConvenioNsaSequence; +}>; + +/** + * Consome o número corrente e devolve a sequência já apontando para o próximo. + * + * Não persiste nada: a ATOMICIDADE entre ler e gravar é do adapter (lock da linha do convênio), e + * não pode ser simulada aqui. Duas gerações concorrentes que leiam o mesmo número produzem arquivos + * com NSA repetido — e repetição, para o banco, é retransmissão. + * + * A sequência pode terminar apontando para fora da faixa, e isso é deliberado: o último número é + * entregue normalmente e só a alocação SEGUINTE falha, em vez de recusar uma remessa legítima por + * antecipação. Mesmo desenho do `allocateNsa` da conta, que este substitui. + */ +export const allocate = ( + sequence: ConvenioNsaSequence, +): Result => { + const current = Nsa.rehydrate(sequence.nextNsa); + if (!current.ok) return err('nsa-exhausted'); + + return ok( + immutable({ + nsa: current.value, + sequence: { convenio: sequence.convenio, nextNsa: sequence.nextNsa + 1 }, + }), + ); +}; + +/** + * A sequência de um convênio que ainda não tem linha. + * + * ⚠️ SÓ para convênio genuinamente novo. Uma sequência que já emitiu números NUNCA nasce daqui — o + * backfill da migration parte do `MAX(next_nsa)` das contas daquele convênio, e reiniciar em `MIN` + * reemitiria a faixa inteira. Reemissão é exatamente o que esta issue existe para impedir, e o + * `your_number` já gravado colidiria no UNIQUE mesmo que o banco não reclamasse. + */ +export const start = (convenio: string): ConvenioNsaSequence => + immutable({ convenio, nextNsa: Nsa.MIN }); diff --git a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts index 7c1b940a..f8d4e1cd 100644 --- a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts +++ b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts @@ -6,7 +6,6 @@ import type { CedenteAccountStore } from '#src/modules/financial/application/por import type { CedenteAccount } from '#src/modules/financial/domain/cedente/types.ts'; import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; import { create, close } from '#src/modules/financial/domain/cedente/cedente-account.ts'; -import * as Nsa from '#src/modules/financial/domain/cedente/nsa.ts'; // Suíte de CONTRATO do `CedenteAccountStore`: todo adapter (in-memory, Drizzle+MySQL) consome esta // função e deve passar. NÃO é executada direto (sufixo `.contract.ts` — ver `.claude/rules/testing.md` @@ -32,7 +31,14 @@ export const CONTRACT_AGENCY = '9001'; // Cada conta nasce com chave natural distinta: o UNIQUE de FR-016 colide se dois casos a reusarem. let naturalKeySeq = 0; -const buildAccount = (nextNsa?: number): CedenteAccount => { + +// ⚠️ O CONVÊNIO TAMBÉM VARIA POR CONTA, e desde a #943 isso é o que isola um caso do outro: o +// contador de NSA é do CONVÊNIO, então duas contas que o compartilhassem dividiriam a sequência e um +// caso enxergaria o número que o anterior gastou. Quem PRECISA do compartilhamento pede +// explicitamente — ver `buildAccount({ convenio })` e o bloco da #943 no fim deste arquivo. +const buildAccount = ( + over: Readonly<{ nextNsa?: number; convenio?: string }> = {}, +): CedenteAccount => { naturalKeySeq += 1; const r = create({ id: CedenteAccountId.generate(), @@ -40,10 +46,10 @@ const buildAccount = (nextNsa?: number): CedenteAccount => { agency: CONTRACT_AGENCY, accountNumber: `7700${String(naturalKeySeq).padStart(2, '0')}`, accountDigit: '4', - convenio: '9999999', + convenio: over.convenio ?? `9${String(naturalKeySeq).padStart(5, '0')}`, document: '12345678000190', nickname: 'Conta do contrato', - ...(nextNsa !== undefined ? { nextNsa } : {}), + ...(over.nextNsa !== undefined ? { nextNsa: over.nextNsa } : {}), }); if (!r.ok) throw new Error(`test setup: cedente (${r.error})`); return r.value; @@ -73,8 +79,11 @@ export const cedenteAccountStoreContract = ( // Semeia uma conta e devolve o snapshot persistido — não o construído: o que interessa a cada caso // é o estado que o store guardou. - const seed = async (nextNsa?: number, status: 'Active' | 'Closed' = 'Active') => { - const built = buildAccount(nextNsa); + const seed = async ( + over: Readonly<{ nextNsa?: number; convenio?: string }> = {}, + status: 'Active' | 'Closed' = 'Active', + ) => { + const built = buildAccount(over); const account = status === 'Closed' ? close(built) : { ok: true as const, value: built }; assert.ok(isOk(account)); assert.ok(isOk(await store.save(account.value))); @@ -86,8 +95,11 @@ export const cedenteAccountStoreContract = ( // ─── save ─────────────────────────────────────────────────────────────────── + // ⚠️ `nextNsa` aqui é o campo VESTIGIAL da conta (#943): desde que a sequência passou para + // `fin_convenio_nsa`, ele não manda mais em alocação nenhuma. O caso continua porque o + // round-trip da coluna ainda é contrato de persistência — não porque o número signifique algo. it('save insere a conta nova e findById devolve o snapshot, inclusive o NSA inicial', async () => { - const account = buildAccount(4); + const account = buildAccount({ nextNsa: 4 }); assert.ok(isOk(await store.save(account))); const found = await store.findById(account.id); @@ -158,29 +170,33 @@ export const cedenteAccountStoreContract = ( // o `save`, o snapshot em mãos do use case carrega o contador OBSOLETO. Um `save` que o gravasse // faria o contador RETROCEDER, e um NSA já emitido seria reemitido — que o banco trata como // RETRANSMISSÃO, não como remessa nova. - it('save com snapshot anterior a uma alocação concorrente não retrocede o contador (lost update)', async () => { - const staleSnapshot = await seed(1); - assert.equal(staleSnapshot.nextNsa, 1); + // ⚠️ ESTE CASO FOI REESCRITO NA #943, e a razão importa mais que o novo corpo. Ele media que o + // `save` não escrevia `fin_cedente_accounts.next_nsa`, porque o contador vivia ali e um snapshot + // obsoleto o faria retroceder. O contador MUDOU DE TABELA: agora é `fin_convenio_nsa`, do + // convênio, e o `save` da conta não tem caminho nenhum até ele. + // + // A propriedade deixou de ser "o `save` não retrocede o contador" e passou a ser ESTRUTURAL: o + // `save` não alcança a sequência. Vale mais, e por isso o caso continua existindo em vez de sair + // — o que ele vigia é que ninguém religue os dois pelo caminho antigo. + it('save com snapshot obsoleto não alcança a sequência (o lost update ficou impossível)', async () => { + const staleSnapshot = await seed(); - // Alocação concorrente avança o contador persistido para 2. const allocated = await store.allocateNsa(staleSnapshot.id); assert.ok(isOk(allocated)); - assert.equal(allocated.value, 1); + assert.equal(allocated.value, 1, 'convênio novo começa no mínimo da faixa'); - // O `save` chega DEPOIS, carregando o snapshot obsoleto — como faria a edição de um `nickname` - // por quem não soube da alocação. + // O `save` chega DEPOIS, com o snapshot que o use case leu antes da alocação — e ainda traz o + // `next_nsa` vestigial da conta, que hoje não manda em nada. assert.ok(isOk(await store.save({ ...staleSnapshot, nickname: 'apelido novo' }))); const found = await store.findById(staleSnapshot.id); assert.ok(isOk(found) && found.value !== null); - assert.equal(found.value.nextNsa, 2, 'o contador retrocedeu: lost update de volta'); - // A edição em si — campo que não é o contador — foi persistida normalmente. - assert.equal(found.value.nickname, 'apelido novo'); + assert.equal(found.value.nickname, 'apelido novo', 'a edição normal foi persistida'); - // E o próximo NSA continua de onde a alocação deixou: o 1 já emitido nunca se repete. + // O que importa: a sequência do convênio seguiu de onde estava. O 1 já emitido não volta. const next = await store.allocateNsa(staleSnapshot.id); assert.ok(isOk(next)); - assert.equal(next.value, 2); + assert.equal(next.value, 2, 'um `save` reposicionou a sequência do convênio'); }); // A garantia é FORTE, e a distinção não é acadêmica: o contrato promete que `save` **não escreve** @@ -189,49 +205,39 @@ export const cedenteAccountStoreContract = ( // update voltaria por essa porta. Reposicionar o NSA, se o banco um dia pedir, é operação rara, // perigosa e auditável: pede método próprio e explícito no port, não carona num `save` que a // edição de um apelido dispara. - it('save não move o contador nem quando o snapshot traz um NSA deliberadamente diferente', async () => { - const account = await seed(5); - assert.equal(account.nextNsa, 5); - - // Para BAIXO (5 → 2): o retrocesso do lost update, agora deliberado. - assert.ok(isOk(await store.save({ ...account, nextNsa: 2 }))); - const afterDown = await store.findById(account.id); - assert.ok(isOk(afterDown) && afterDown.value !== null); - assert.equal(afterDown.value.nextNsa, 5, 'o contador retrocedeu por um `save`'); + // A garantia continua FORTE e mudou de alvo (#943): nenhum valor que o chamador ponha no + // agregado — em direção nenhuma — reposiciona a sequência do convênio. Reposicionar o NSA, se o + // banco um dia pedir, é operação rara, perigosa e auditável: pede método próprio e explícito no + // port, não carona num `save` que a edição de um apelido dispara. + it('save não move a sequência nem quando o snapshot traz um NSA deliberadamente diferente', async () => { + const account = await seed(); - // Para CIMA (5 → 40): o "pulo" deliberado, recusado pela mesma razão — `save` não é caminho de - // escrita do contador em direção nenhuma. + // Para CIMA e para BAIXO: os dois são ignorados, porque o campo da conta não é mais o contador. assert.ok(isOk(await store.save({ ...account, nextNsa: 40 }))); - const afterUp = await store.findById(account.id); - assert.ok(isOk(afterUp) && afterUp.value !== null); - assert.equal( - afterUp.value.nextNsa, - 5, - 'o contador avançou por um `save`, não por `allocateNsa`', - ); + assert.ok(isOk(await store.save({ ...account, nextNsa: 2 }))); - // E a alocação segue de onde o contador realmente está — 5, nem 2 nem 40. + // A sequência do convênio nunca foi tocada: continua no primeiro número. const next = await store.allocateNsa(account.id); assert.ok(isOk(next)); - assert.equal(next.value, 5); + assert.equal(next.value, 1, 'um `save` moveu a sequência do convênio'); }); // ─── allocateNsa ──────────────────────────────────────────────────────────── - it('allocateNsa devolve o número corrente e avança o contador persistido', async () => { - const account = await seed(5); + it('allocateNsa devolve o número corrente e avança a sequência persistida', async () => { + const account = await seed(); const first = await store.allocateNsa(account.id); assert.ok(isOk(first)); - assert.equal(first.value, 5); + assert.equal(first.value, 1); - const found = await store.findById(account.id); - assert.ok(isOk(found) && found.value !== null); - assert.equal(found.value.nextNsa, 6); + const second = await store.allocateNsa(account.id); + assert.ok(isOk(second)); + assert.equal(second.value, 2, 'a sequência não avançou entre as duas alocações'); }); it('allocateNsa em chamadas sucessivas nunca repete o número', async () => { - const account = await seed(1); + const account = await seed(); const allocated: number[] = []; for (let i = 0; i < 5; i += 1) { @@ -250,31 +256,72 @@ export const cedenteAccountStoreContract = ( assert.equal(r.error, 'cedente-account-not-found'); }); - it('allocateNsa em conta encerrada não aloca e não move o contador', async () => { - const account = await seed(3, 'Closed'); + /* + * ⚠️ CA1 DA #943 — O CASO QUE REPRODUZ O BLOQUEIO DE PRODUÇÃO, e que nenhuma suíte tinha. + * + * O mesmo contrato multipag vale para VÁRIAS contas de pagamento (confirmado com o gerente do + * Bradesco em 02/09/2026). Enquanto o contador viveu em `fin_cedente_accounts.next_nsa`, cada + * conta nova nascia em 1 e as duas emitiam o MESMO número sob o mesmo contrato. + * + * O dano não esperava o banco: `fin_remittance_payables.your_number` é + * ``, com UNIQUE global e SEM componente de tempo. As duas contas + * geravam a mesma referência para o primeiro título, e o segundo INSERT era recusado — 503 + * opaco, conta sem conseguir gerar remessa (#942). + * + * Este caso falha contra o modelo antigo: as duas alocações devolviam 1. + */ + it('CA1: contas do MESMO convênio compartilham a sequência — números distintos e crescentes', async () => { + const convenio = '918002'; + const first = await seed({ convenio }); + const second = await seed({ convenio }); + + const a = await store.allocateNsa(first.id); + const b = await store.allocateNsa(second.id); + assert.ok(isOk(a) && isOk(b)); + + assert.equal(a.value, 1); + assert.equal(b.value, 2, 'a conta irmã reemitiu o número — é o defeito da #943'); + assert.notEqual(a.value, b.value, 'NSA repetido sob o mesmo contrato é retransmissão'); + }); - const r = await store.allocateNsa(account.id); - assert.ok(isErr(r)); - assert.equal(r.error, 'cedente-account-not-active'); + // CA2 — o outro lado: convênios distintos NÃO se conhecem. Não é critério defensivo; o cliente + // tem contas com convênios diferentes, e cada contrato tem a sua série junto ao banco. + it('CA2: convênios distintos mantêm sequências independentes', async () => { + const here = await seed({ convenio: '918003' }); + const there = await seed({ convenio: '918004' }); - const found = await store.findById(account.id); - assert.ok(isOk(found) && found.value !== null); - assert.equal(found.value.nextNsa, 3); + const a = await store.allocateNsa(here.id); + const b = await store.allocateNsa(there.id); + assert.ok(isOk(a) && isOk(b)); + + assert.equal(a.value, 1); + assert.equal(b.value, 1, 'o primeiro número de um contrato gastou o do outro'); }); - // O teto não é arbitrário: o NSA ocupa seis dígitos no header de arquivo (Multipag p. 14). Alocar - // além dele gravaria um número que não cabe no campo — o defeito só apareceria na serialização, - // com a remessa inteira já montada. - it('allocateNsa com a faixa esgotada falha sem estourar o campo de seis dígitos', async () => { - const account = await seed(Nsa.MAX); + // ⚠️ A ORDEM DENTRO DA TRANSAÇÃO, e é por isso que este caso confere a sequência DEPOIS: a conta + // é verificada ANTES de a sequência ser tocada. Invertida, uma conta encerrada queimaria um + // número do convênio INTEIRO — e o número não volta. Com o contador na conta o dano ficava + // contido nela; agora ele atingiria todas as contas irmãs. + it('allocateNsa em conta encerrada não aloca e não queima número do convênio', async () => { + const convenio = '918001'; + const closed = await seed({ convenio }, 'Closed'); + const active = await seed({ convenio }); - const last = await store.allocateNsa(account.id); - assert.ok(isOk(last)); - assert.equal(last.value, Nsa.MAX); + const r = await store.allocateNsa(closed.id); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-account-not-active'); - const beyond = await store.allocateNsa(account.id); - assert.ok(isErr(beyond)); - assert.equal(beyond.error, 'nsa-exhausted'); + // A conta IRMÃ, ativa e sob o mesmo convênio, recebe o primeiro número — prova de que a + // tentativa recusada não consumiu nada da sequência compartilhada. + const next = await store.allocateNsa(active.id); + assert.ok(isOk(next)); + assert.equal(next.value, 1, 'a conta encerrada queimou um número do convênio'); }); + + // O teto (seis dígitos no header, Multipag p. 14) é regra de DOMÍNIO e está coberto em + // `nsa-sequence.test.ts`, que alcança `Nsa.MAX` sem precisar de banco. Aqui ele saiu de propósito: + // com o contador fora da conta, criar a conta deixou de posicionar a sequência, e chegar ao teto + // pelo contrato exigiria 999.999 alocações ou um caminho de escrita direto — que é exatamente o + // que o caso acima existe para negar. }); }; diff --git a/tests/modules/financial/application/use-cases/generate-remittance.test.ts b/tests/modules/financial/application/use-cases/generate-remittance.test.ts index 08498d88..5f93cb36 100644 --- a/tests/modules/financial/application/use-cases/generate-remittance.test.ts +++ b/tests/modules/financial/application/use-cases/generate-remittance.test.ts @@ -124,6 +124,27 @@ const input = ( // juntos), então uma partição que os separasse por engano seria um defeito. Um `files[0]` cru // esconderia isso: os asserts seguintes passariam sobre o primeiro arquivo, e o segundo — que não // devia existir — não seria olhado por ninguém. +/* + * "A recusa NÃO queimou um número da sequência" — e este helper existe porque a forma anterior de + * afirmar isso ficou VAZIA (#943). + * + * Os casos conferiam `fin_cedente_accounts.next_nsa` depois da recusa. Enquanto o contador vivia na + * conta, isso media o fato certo. Com a sequência no CONVÊNIO, a coluna da conta não se move NUNCA — + * nem no sucesso —, então o assert passaria com o NSA queimado ou não. Asserção que não pode falhar + * é pior que asserção nenhuma: ela ocupa o lugar da que faltava. + * + * A prova honesta é observável pelo desfecho: se nada foi consumido, a PRÓXIMA geração bem-sucedida + * naquele convênio recebe o PRIMEIRO número. + */ +const assertNoNsaBurned = async ( + s: Awaited>, + cedenteAccountId = s.cedenteAccountId, +) => { + const allocated = await s.accounts.allocateNsa(cedenteAccountId); + assert.ok(isOk(allocated), 'não foi possível alocar depois da recusa'); + assert.equal(allocated.value, 1, 'a recusa consumiu um número da sequência do convênio'); +}; + const onlyFile = (out: Readonly<{ files: readonly GeneratedRemittanceFile[] }>) => { assert.equal(out.files.length, 1, 'esperava uma geração de arquivo único'); const file = out.files[0]; @@ -165,13 +186,29 @@ describe('generateRemittance — caminho feliz', () => { ); }); - it('consome o NSA da conta', async () => { + // ⚠️ REESCRITO NA #943, e a razão é que a versão anterior media a coluna errada. Ela conferia + // `fin_cedente_accounts.next_nsa`, que era o contador — e deixou de ser: a sequência passou para o + // CONVÊNIO (`fin_convenio_nsa`), e a coluna da conta virou vestigial. Continuar assertando sobre + // ela produziria um caso que passa sem medir nada. + // + // O que prova o consumo agora é a alocação SEGUINTE: se o número foi consumido, a próxima remessa + // recebe o seguinte. É observável pelo desfecho, que é onde a garantia importa. + it('consome o NSA — a geração seguinte recebe o número seguinte', async () => { const s = await setup(); - await generateRemittance(s.deps)(input(s.cedenteAccountId, s.docs)); - const acc = await s.accounts.findById(s.cedenteAccountId); - assert.ok(isOk(acc) && acc.value !== null); - assert.equal(acc.value.nextNsa, 2); + const first = await generateRemittance(s.deps)(input(s.cedenteAccountId, s.docs)); + assert.ok(isOk(first)); + assert.equal(onlyFile(first.value).nsa, 1); + + const other = await setup({ docs: ['doc-3', 'doc-4'] }); + const second = await generateRemittance({ + ...other.deps, + // MESMA conta-cedente da primeira geração: é a sequência dela que tem de ter avançado. + cedenteAccounts: s.accounts, + })(input(s.cedenteAccountId, other.docs)); + + assert.ok(isOk(second), `esperava ok, veio ${isErr(second) ? second.error : '?'}`); + assert.equal(onlyFile(second.value).nsa, 2, 'o NSA não avançou entre duas gerações'); }); }); @@ -305,10 +342,7 @@ describe('generateRemittance — os campos do cedente no header (#856)', () => { assert.ok(isErr(r)); assert.equal(r.error, 'cedente-agency-malformed'); - - const acc = await s.accounts.findById(s.cedenteAccountId); - assert.ok(isOk(acc) && acc.value !== null); - assert.equal(acc.value.nextNsa, 1, 'a recusa não pode consumir um número da sequência'); + await assertNoNsaBurned(s); }); // ── CA3 — o CNPJ alfanumérico do cedente ──────────────────────────────────────────────────── @@ -327,10 +361,7 @@ describe('generateRemittance — os campos do cedente no header (#856)', () => { assert.ok(isErr(r)); assert.equal(r.error, 'cedente-inscription-alphanumeric'); - - const acc = await s.accounts.findById(s.cedenteAccountId); - assert.ok(isOk(acc) && acc.value !== null); - assert.equal(acc.value.nextNsa, 1, 'a recusa não pode consumir um número da sequência'); + await assertNoNsaBurned(s); }); }); diff --git a/tests/modules/financial/domain/cedente/nsa-sequence.test.ts b/tests/modules/financial/domain/cedente/nsa-sequence.test.ts new file mode 100644 index 00000000..d3e91a44 --- /dev/null +++ b/tests/modules/financial/domain/cedente/nsa-sequence.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { isErr, isOk } from '#src/shared/index.ts'; +import * as NsaSequence from '#src/modules/financial/domain/cedente/nsa-sequence.ts'; +import * as Nsa from '#src/modules/financial/domain/cedente/nsa.ts'; + +/** + * A sequência de NSA pertence ao CONVÊNIO, não à conta-cedente (#943). + * + * O que estes casos protegem é a propriedade que o defeito violava: um contrato multipag, uma + * sequência — quantas contas de pagamento existam sob ele. Enquanto o contador viveu na conta, cada + * conta nova nascia em 1 e duas contas irmãs emitiam o mesmo número sob o mesmo contrato. + */ +describe('nsa-sequence — o contador do convênio (#943)', () => { + it('consome o número corrente e aponta para o próximo', () => { + const r = NsaSequence.allocate({ convenio: '123456', nextNsa: 7 }); + + assert.ok(isOk(r)); + assert.equal(r.value.nsa, 7); + assert.equal(r.value.sequence.nextNsa, 8, 'a sequência tem de avançar'); + assert.equal(r.value.sequence.convenio, '123456', 'o dono da sequência não muda'); + }); + + it('chamadas sucessivas nunca repetem o número', () => { + let sequence = NsaSequence.start('123456'); + const emitted: number[] = []; + + for (let i = 0; i < 5; i += 1) { + const r = NsaSequence.allocate(sequence); + assert.ok(isOk(r)); + emitted.push(r.value.nsa); + sequence = r.value.sequence; + } + + assert.deepEqual(emitted, [1, 2, 3, 4, 5]); + assert.equal( + new Set(emitted).size, + emitted.length, + 'NSA repetido é retransmissão para o banco', + ); + }); + + // ⚠️ CA2 — sequências de convênios distintos não se conhecem. É a metade que impede a correção de + // virar um contador global: o cliente PODE ter contas com convênios diferentes, e cada contrato + // tem a sua série junto ao banco. + it('CA2: convênios distintos caminham independentes', () => { + const a = NsaSequence.allocate(NsaSequence.start('111111')); + const b = NsaSequence.allocate(NsaSequence.start('222222')); + + assert.ok(isOk(a) && isOk(b)); + assert.equal(a.value.nsa, 1); + assert.equal(b.value.nsa, 1, 'o número 1 de um contrato não gasta o 1 do outro'); + }); + + // O último número da faixa é entregue normalmente; só a alocação SEGUINTE falha. Recusar por + // antecipação negaria uma remessa legítima. + it('entrega o último número da faixa, e só então esgota', () => { + const last = NsaSequence.allocate({ convenio: '123456', nextNsa: Nsa.MAX }); + assert.ok(isOk(last)); + assert.equal(last.value.nsa, Nsa.MAX); + + const beyond = NsaSequence.allocate(last.value.sequence); + assert.ok(isErr(beyond)); + assert.equal(beyond.error, 'nsa-exhausted'); + }); + + // ⚠️ `start` é SÓ para convênio genuinamente novo. Este caso fixa o valor porque o backfill da + // migration depende dele: quem já emitiu nasce do `MAX`, nunca daqui. + it('convênio novo nasce no mínimo da faixa', () => { + assert.equal(NsaSequence.start('123456').nextNsa, Nsa.MIN); + }); +}); From d8c9a427c025549b150d1ae7f41d06f8d6da0832 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 15:34:19 -0300 Subject: [PATCH 06/13] =?UTF-8?q?fix(financial):=20o=20CA3=20acha=20um=20d?= =?UTF-8?q?eadlock=20na=20aloca=C3=A7=C3=A3o=20por=20conv=C3=AAnio,=20e=20?= =?UTF-8?q?ele=20=C3=A9=20consertado=20(#943)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../repos/cedente-account-store.drizzle.ts | 53 ++++--- .../cedente-account-store.contract.ts | 10 +- .../nsa-allocation.drizzle-mysql.test.ts | 142 +++++++++++++++--- 3 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index 51f043fd..e63a54b8 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -17,7 +17,7 @@ import type { } from '#src/modules/financial/application/ports/cedente-account-store.ts'; import { isActive } from '#src/modules/financial/domain/cedente/cedente-account.ts'; import * as NsaSequence from '#src/modules/financial/domain/cedente/nsa-sequence.ts'; -import type { Nsa } from '#src/modules/financial/domain/cedente/nsa.ts'; +import * as Nsa from '#src/modules/financial/domain/cedente/nsa.ts'; import type { FinancialMysqlHandle } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; import { finCedenteAccounts, finConvenioNsa } from '../schemas/mysql.ts'; import { toRow, toDomain } from '../mappers/cedente-account.mapper.ts'; @@ -194,7 +194,7 @@ export const createDrizzleCedenteAccountStore = ( // A ORDEM DENTRO DA TRANSAÇÃO É REGRA, não arrumação: lê a conta e confere que ela paga ANTES // de tocar a sequência. Invertida, uma conta encerrada queimaria um número do convênio inteiro — // e o número não volta. - allocateNsa: async (id: CedenteAccountId): Promise> => { + allocateNsa: async (id: CedenteAccountId): Promise> => { try { return await db.transaction(async (tx) => { // 1. A CONTA — sem lock: ela não é mais o recurso disputado, só a origem do convênio e a @@ -223,7 +223,26 @@ export const createDrizzleCedenteAccountStore = ( const convenio = account.value.convenio.trim(); if (convenio === '') return err('cedente-account-not-found'); - // 2. A SEQUÊNCIA — COM lock. É aqui que as contas irmãs se serializam. + // 2. MATERIALIZA a linha da sequência ANTES de travá-la. + // + // ⚠️ ESTE INSERT NÃO É REDUNDANTE, e removê-lo traz de volta um DEADLOCK sob concorrência — + // medido pelo CA3 da #943, que falhou com `cedente-account-store-unavailable` antes desta + // linha existir. Em REPEATABLE READ (o default), um `SELECT … FOR UPDATE` que não encontra + // linha trava o GAP, não uma linha: N transações do mesmo convênio novo pegam o mesmo gap, + // todas seguem, e todas tentam inserir a mesma PK — `ER_LOCK_DEADLOCK`. É a mesma armadilha + // que `.claude/rules/adapters.md` registra para o claim do outbox. + // + // Com a linha materializada primeiro, o `ON DUPLICATE KEY UPDATE` vira lock de LINHA na PK: + // a segunda transação espera a primeira e depois enxerga o valor dela. O `set` é no-op de + // propósito — quem move o contador é o UPDATE do passo 4, e escrever aqui sobrescreveria o + // trabalho de quem chegou antes. + await tx + .insert(finConvenioNsa) + .values({ convenio, nextNsa: Nsa.MIN }) + .onDuplicateKeyUpdate({ set: { convenio } }); + + // 3. A SEQUÊNCIA — COM lock, e agora sobre uma linha que existe. É aqui que as contas irmãs + // se serializam. const sequenceRows = await tx .select() .from(finConvenioNsa) @@ -232,24 +251,24 @@ export const createDrizzleCedenteAccountStore = ( .limit(1); const sequenceRow = sequenceRows[0]; - // Convênio sem linha é convênio genuinamente novo — nasce em `Nsa.MIN`. O backfill da - // migration já criou linha para todo convênio que JÁ emitiu, partindo do `MAX`, então - // chegar aqui sem linha não pode reemitir faixa usada. - const sequence = - sequenceRow === undefined - ? NsaSequence.start(convenio) - : { convenio: sequenceRow.convenio, nextNsa: sequenceRow.nextNsa }; + // Inalcançável: o INSERT acima garante a linha. Explícito porque a alternativa seria um + // não-nulo assumido — e se um dia deixar de valer, o erro aqui é melhor que um NSA inventado. + if (sequenceRow === undefined) { + logStore('allocateNsa', 'sequência do convênio ausente após o insert'); + return err('cedente-account-store-unavailable'); + } - const allocation = NsaSequence.allocate(sequence); + const allocation = NsaSequence.allocate({ + convenio: sequenceRow.convenio, + nextNsa: sequenceRow.nextNsa, + }); if (!allocation.ok) return err('nsa-exhausted'); - // `ON DUPLICATE KEY UPDATE` cobre o convênio novo e o existente num caminho só. Duas - // gerações concorrentes do MESMO convênio novo não passam as duas: a segunda espera no - // lock da PK e enxerga a linha da primeira. + // 4. AVANÇA. Só aqui o contador se move, e sob o lock adquirido no passo 3. await tx - .insert(finConvenioNsa) - .values({ convenio, nextNsa: allocation.value.sequence.nextNsa }) - .onDuplicateKeyUpdate({ set: { nextNsa: allocation.value.sequence.nextNsa } }); + .update(finConvenioNsa) + .set({ nextNsa: allocation.value.sequence.nextNsa }) + .where(eq(finConvenioNsa.convenio, convenio)); return ok(allocation.value.nsa); }); diff --git a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts index f8d4e1cd..d33ea164 100644 --- a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts +++ b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts @@ -29,6 +29,13 @@ import { create, close } from '#src/modules/financial/domain/cedente/cedente-acc // recorte por agência É o espaço de chave que o arquivo escreve, e não atinge os irmãos). export const CONTRACT_AGENCY = '9001'; +// ⚠️ O PREFIXO DO CONVÊNIO É ESPAÇO DE CHAVE, como a agência (#943). Desde que a sequência de NSA +// passou para `fin_convenio_nsa`, o consumidor Drizzle precisa limpar TAMBÉM aquela tabela na +// entrada — senão a segunda execução contra o mesmo MySQL herda o contador da primeira, e os casos +// que esperam começar em 1 falham. É a regra de `.claude/rules/testing.md` §"passar DUAS VEZES +// seguidas": a linha da sequência sobrevive ao `DELETE` das contas, porque não tem FK para elas. +export const CONTRACT_CONVENIO_PREFIX = '91'; + // Cada conta nasce com chave natural distinta: o UNIQUE de FR-016 colide se dois casos a reusarem. let naturalKeySeq = 0; @@ -46,7 +53,8 @@ const buildAccount = ( agency: CONTRACT_AGENCY, accountNumber: `7700${String(naturalKeySeq).padStart(2, '0')}`, accountDigit: '4', - convenio: over.convenio ?? `9${String(naturalKeySeq).padStart(5, '0')}`, + convenio: + over.convenio ?? `${CONTRACT_CONVENIO_PREFIX}${String(naturalKeySeq).padStart(4, '0')}`, document: '12345678000190', nickname: 'Conta do contrato', ...(over.nextNsa !== undefined ? { nextNsa: over.nextNsa } : {}), diff --git a/tests/modules/financial/adapters/persistence/nsa-allocation.drizzle-mysql.test.ts b/tests/modules/financial/adapters/persistence/nsa-allocation.drizzle-mysql.test.ts index 99a049d4..496c52d3 100644 --- a/tests/modules/financial/adapters/persistence/nsa-allocation.drizzle-mysql.test.ts +++ b/tests/modules/financial/adapters/persistence/nsa-allocation.drizzle-mysql.test.ts @@ -16,16 +16,23 @@ import { describe, it, before, beforeEach, after } from 'node:test'; import { strict as assert } from 'node:assert'; import process from 'node:process'; -import { eq } from 'drizzle-orm'; +import { eq, like } from 'drizzle-orm'; import { openMysqlFinancial } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; import type { FinancialMysqlHandle } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; import { createDrizzleCedenteAccountStore } from '#src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts'; -import { finCedenteAccounts } from '#src/modules/financial/adapters/persistence/schemas/mysql.ts'; +import { + finCedenteAccounts, + finConvenioNsa, +} from '#src/modules/financial/adapters/persistence/schemas/mysql.ts'; import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; import { create } from '#src/modules/financial/domain/cedente/cedente-account.ts'; import { mysqlTestConnectionString } from '#tests/support/mysql-conn.ts'; -import { CONTRACT_AGENCY, cedenteAccountStoreContract } from './cedente-account-store.contract.ts'; +import { + CONTRACT_AGENCY, + CONTRACT_CONVENIO_PREFIX, + cedenteAccountStoreContract, +} from './cedente-account-store.contract.ts'; // Chave natural distinta por conta: o UNIQUE da migration 0009 colide se dois casos reusarem // 237/1234/567890/1. Agência própria deste arquivo — o contrato usa a sua (`CONTRACT_AGENCY`). @@ -35,8 +42,14 @@ import { CONTRACT_AGENCY, cedenteAccountStoreContract } from './cedente-account- */ const OWN_AGENCY = '4321'; +// ⚠️ Prefixo PRÓPRIO de convênio, distinto do que o contrato usa (#943). Desde que a sequência de +// NSA passou para `fin_convenio_nsa`, o convênio virou espaço de chave tanto quanto a agência: dois +// arquivos que o compartilhassem dividiriam o contador, e o que rodasse depois herdaria o número do +// outro. Limpo na entrada, como a agência. +const OWN_CONVENIO_PREFIX = '92'; + let naturalKeySeq = 0; -const buildAccount = (nextNsa?: number) => { +const buildAccount = (over: Readonly<{ nextNsa?: number; convenio?: string }> = {}) => { naturalKeySeq += 1; const r = create({ id: CedenteAccountId.generate(), @@ -44,9 +57,9 @@ const buildAccount = (nextNsa?: number) => { agency: OWN_AGENCY, accountNumber: `9900${String(naturalKeySeq).padStart(2, '0')}`, accountDigit: '7', - convenio: '9999999', + convenio: over.convenio ?? `${OWN_CONVENIO_PREFIX}${String(naturalKeySeq).padStart(4, '0')}`, document: '12345678000190', - ...(nextNsa !== undefined ? { nextNsa } : {}), + ...(over.nextNsa !== undefined ? { nextNsa: over.nextNsa } : {}), }); if (!r.ok) throw new Error(`test setup: cedente (${r.error})`); return r.value; @@ -75,8 +88,16 @@ if (!process.env['MYSQL_INTEGRATION']) { // usam `OWN_AGENCY` e precisam do mesmo tratamento. `naturalKeySeq` reinicia a cada processo, e // sem esta limpeza a 2ª execução colide em `fin_cedente_accounts_natural_key_uq` — em silêncio, // porque o `save` é upsert (vira UPDATE da linha antiga e o id novo nunca entra). + // ⚠️ A SEQUÊNCIA TAMBÉM É RESÍDUO, e não tem FK para a conta (#943): apagar `fin_cedente_accounts` + // deixa a linha de `fin_convenio_nsa` viva, com o contador onde o run anterior o deixou. A + // segunda execução contra o mesmo MySQL veria o NSA começar em 11 em vez de 1 — a falha que + // `.claude/rules/testing.md` §"passar DUAS VEZES seguidas" existe para pegar, e que a inversão de + // ordem NÃO pega (as duas ordens partem de banco limpo). beforeEach(async () => { await handle.db.delete(finCedenteAccounts).where(eq(finCedenteAccounts.agency, OWN_AGENCY)); + await handle.db + .delete(finConvenioNsa) + .where(like(finConvenioNsa.convenio, `${OWN_CONVENIO_PREFIX}%`)); }); after(async () => { @@ -93,6 +114,11 @@ if (!process.env['MYSQL_INTEGRATION']) { await handle.db .delete(finCedenteAccounts) .where(eq(finCedenteAccounts.agency, CONTRACT_AGENCY)); + // A sequência do convênio é o segundo espaço de chave do contrato (#943) — ver + // `CONTRACT_CONVENIO_PREFIX`. Sem esta linha, o re-run herda o contador. + await handle.db + .delete(finConvenioNsa) + .where(like(finConvenioNsa.convenio, `${CONTRACT_CONVENIO_PREFIX}%`)); }, })); @@ -120,9 +146,82 @@ if (!process.env['MYSQL_INTEGRATION']) { [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], ); - const reloaded = await store.findById(account.id); - assert.ok(reloaded.ok && reloaded.value !== null); - assert.equal(reloaded.value.nextNsa, 11); + // A sequência avançou para 11 — e a conferência é pela ALOCAÇÃO, não pela coluna da conta: + // desde a #943 o contador vive em `fin_convenio_nsa`, e `fin_cedente_accounts.next_nsa` é + // vestigial. Conferir a coluna antiga aqui passaria sem medir nada. + const next = await store.allocateNsa(account.id); + assert.ok(next.ok); + assert.equal(next.value, 11); + }); + + /* + * ⚠️ CA3 DA #943 — E ELE É A RAZÃO DE A CORREÇÃO PRECISAR DE MySQL REAL. + * + * O caso acima dispara dez alocações da MESMA conta; ele já passava antes da #943, porque o lock + * era da linha da conta e as dez disputavam a mesma linha. O defeito estava em outro lugar: duas + * contas DIFERENTES sob o mesmo convênio não disputavam nada — cada uma tinha o seu contador, e + * ambas partiam de 1. + * + * Agora o `SELECT … FOR UPDATE` locka a linha do CONVÊNIO, então a serialização passa a existir + * entre contas irmãs. Nenhum fake demonstra isso: a garantia é do InnoDB. + * + * ⚠️ `poolLimit: 8` (ver `before`) é o que dá sentido ao caso. Com uma conexão só, as + * "concorrentes" seriam serializadas pelo pool e o teste passaria mesmo sem lock nenhum. + */ + it('CA3: alocações concorrentes de contas DIFERENTES do mesmo convênio não repetem número', async () => { + const store = createDrizzleCedenteAccountStore(handle); + const convenio = `${OWN_CONVENIO_PREFIX}7788`; + + const accounts = await Promise.all( + Array.from({ length: 6 }, async () => { + const account = buildAccount({ convenio }); + assert.equal((await store.save(account)).ok, true); + return account; + }), + ); + + const results = await Promise.all( + accounts.map(async (account) => store.allocateNsa(account.id)), + ); + + const allocated = results.map((r) => { + assert.ok(r.ok, `alocação falhou: ${r.ok ? '' : r.error}`); + return r.value as number; + }); + + assert.equal( + new Set(allocated).size, + accounts.length, + `contas irmãs receberam o MESMO NSA: ${allocated.join(',')} — é o defeito da #943, e o banco lê retransmissão`, + ); + assert.deepEqual( + [...allocated].sort((a, b) => a - b), + [1, 2, 3, 4, 5, 6], + 'a sequência do convênio tem de ser contínua entre as contas', + ); + }); + + // O outro lado do CA3, e ele impede a correção de virar um contador global: convênios distintos + // não se serializam nem se gastam. O cliente TEM contas com convênios diferentes. + it('CA2: contas de convênios distintos alocam em paralelo sem se gastar', async () => { + const store = createDrizzleCedenteAccountStore(handle); + + const accounts = await Promise.all( + Array.from({ length: 4 }, async (_unused, i) => { + const account = buildAccount({ convenio: `${OWN_CONVENIO_PREFIX}66${String(i)}0` }); + assert.equal((await store.save(account)).ok, true); + return account; + }), + ); + + const results = await Promise.all( + accounts.map(async (account) => store.allocateNsa(account.id)), + ); + + for (const r of results) { + assert.ok(r.ok); + assert.equal(r.value, 1, 'a série de um contrato consumiu a de outro'); + } }); // O outro caminho de lost update — DETERMINÍSTICO, e por isso mais fácil de disparar que a @@ -134,12 +233,16 @@ if (!process.env['MYSQL_INTEGRATION']) { // fake in-memory decide "linha existe?" por id e nunca reproduz a colisão. it('save de conta NOVA com chave natural já existente não zera o contador da linha existente', async () => { const store = createDrizzleCedenteAccountStore(handle); + // ⚠️ Agência e convênio DENTRO do espaço que o `beforeEach` limpa. Antes este caso usava + // `4322`/`9999999`, fora dos dois recortes — a linha sobrevivia ao run e a segunda execução + // partia de um contador desconhecido. O `accountNumber` é que distingue esta conta das de + // `buildAccount` (`9900NN`), e ele basta para a chave natural não colidir. const naturalKey = { bankCode: '237', - agency: '4322', + agency: OWN_AGENCY, accountNumber: '880011', accountDigit: '9', - convenio: '9999999', + convenio: `${OWN_CONVENIO_PREFIX}0011`, document: '12345678000190', }; @@ -147,13 +250,10 @@ if (!process.env['MYSQL_INTEGRATION']) { assert.ok(first.ok); assert.equal((await store.save(first.value)).ok, true); - // A conta roda e o contador avança. + // A conta roda e a sequência do convênio avança. for (let i = 0; i < 3; i += 1) { assert.equal((await store.allocateNsa(first.value.id)).ok, true); } - const advanced = await store.findById(first.value.id); - assert.ok(advanced.ok && advanced.value !== null); - assert.equal(advanced.value.nextNsa, 4); // Segunda criação da MESMA conta bancária, com id novo e `nextNsa` inicial (1). const second = create({ id: CedenteAccountId.generate(), ...naturalKey }); @@ -165,15 +265,13 @@ if (!process.env['MYSQL_INTEGRATION']) { assert.ok(foundB.ok); assert.equal(foundB.value, null); - // E o contador de A NÃO retrocedeu para 1 — `next_nsa` está fora do `set` do upsert. - const reloaded = await store.findById(first.value.id); - assert.ok(reloaded.ok && reloaded.value !== null); - assert.equal(reloaded.value.nextNsa, 4, 'o contador foi zerado pela recriação da conta'); - - // O próximo NSA continua a série: os três já emitidos nunca se repetem. + // ⚠️ E A SEQUÊNCIA NÃO RETROCEDEU. Desde a #943 a garantia é mais forte que antes: o contador + // nem mora mais na linha que o upsert toca — está em `fin_convenio_nsa`, e `save` não tem + // caminho até lá. Conferir isso pela ALOCAÇÃO, e não pela coluna vestigial da conta, é o que + // mantém o caso medindo alguma coisa. const next = await store.allocateNsa(first.value.id); assert.ok(next.ok); - assert.equal(next.value, 4); + assert.equal(next.value, 4, 'a recriação da conta zerou a sequência do convênio'); }); }); } From 00635642edb663acfbf2ec88e5a0da1e490ea782 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 15:44:48 -0300 Subject: [PATCH 07/13] test(financial): o backfill da 0057 passa a ser exercitado com dados, e vira idempotente (#943 CA4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/ci/test-integration.ts | 5 + .../mysql/0057_lying_vindicator.sql | 8 +- ...onvenio-nsa-backfill.drizzle-mysql.test.ts | 233 ++++++++++++++++++ 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 tests/modules/financial/adapters/persistence/convenio-nsa-backfill.drizzle-mysql.test.ts diff --git a/scripts/ci/test-integration.ts b/scripts/ci/test-integration.ts index 4970a6f9..805687c8 100644 --- a/scripts/ci/test-integration.ts +++ b/scripts/ci/test-integration.ts @@ -145,6 +145,11 @@ const SUITES: Readonly> = { // Alocação de NSA sob concorrência real: prova que o SELECT ... FOR UPDATE serializa e que dois // pedidos simultâneos nunca recebem o mesmo número. NSA repetido = retransmissão para o banco. 'tests/modules/financial/adapters/persistence/nsa-allocation.drizzle-mysql.test.ts', + // Backfill da migration 0057 (#943, CA4): a sequência de cada convênio nasce em MAX(next_nsa). + // ⚠️ Existe porque as migrations rodam contra banco VAZIO no `before` de cada arquivo — um + // `INSERT … SELECT` sobre tabela vazia passa sem inserir nada. Em produção ela roda sobre contas + // reais, no job de migration, ANTES de o app subir: falhar ali derruba o deploy. + 'tests/modules/financial/adapters/persistence/convenio-nsa-backfill.drizzle-mysql.test.ts', // Remessa (migration 0044): cabeçalho + vínculos na mesma transação, anti-join de documento // preso no BANCO, e os UNIQUEs de NSA-por-conta e nome de arquivo. 'tests/modules/financial/adapters/persistence/remittance-repository.drizzle-mysql.test.ts', diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql b/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql index 345bcff4..f5ab7d5e 100644 --- a/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql +++ b/src/modules/financial/adapters/persistence/migrations/mysql/0057_lying_vindicator.sql @@ -34,8 +34,14 @@ CREATE TABLE `fin_convenio_nsa` ( -- -- Convênio vazio fica de fora: sem convênio não há contrato a que a sequência pertença, e a conta -- já é recusada antes do NSA por `checkCedenteConvenio` (`cedente-convenio-missing`). +-- ⚠️ `GREATEST` no `ON DUPLICATE KEY UPDATE` torna o statement IDEMPOTENTE e monotônico. A migration +-- roda uma vez pelo journal, mas o job que a executa vive num pipeline de deploy que pode ser +-- retentado — e um `INSERT` cru abortaria com `ER_DUP_ENTRY` na segunda passada, derrubando o deploy +-- ANTES de o app subir. `GREATEST` também garante o invariante que importa: reexecutar NUNCA faz o +-- contador retroceder, porque só o maior valor sobrevive. INSERT INTO `fin_convenio_nsa` (`convenio`, `next_nsa`) SELECT TRIM(`convenio`), MAX(`next_nsa`) FROM `fin_cedente_accounts` WHERE TRIM(`convenio`) <> '' -GROUP BY TRIM(`convenio`); +GROUP BY TRIM(`convenio`) +ON DUPLICATE KEY UPDATE `next_nsa` = GREATEST(`fin_convenio_nsa`.`next_nsa`, VALUES(`next_nsa`)); diff --git a/tests/modules/financial/adapters/persistence/convenio-nsa-backfill.drizzle-mysql.test.ts b/tests/modules/financial/adapters/persistence/convenio-nsa-backfill.drizzle-mysql.test.ts new file mode 100644 index 00000000..6af0db69 --- /dev/null +++ b/tests/modules/financial/adapters/persistence/convenio-nsa-backfill.drizzle-mysql.test.ts @@ -0,0 +1,233 @@ +// Teste de integração: o BACKFILL da migration 0057 (#943, CA4) contra MySQL real. +// +// ⚠️ POR QUE ELE PRECISA EXISTIR, e por que o resto da suíte não o cobre: as migrations rodam no +// `before` de cada arquivo de integração, contra um banco VAZIO. Um `INSERT … SELECT` sobre tabela +// vazia insere zero linhas e passa — então a migration mais perigosa do módulo é a única que o CI +// exercita sem dados. Em produção ela roda sobre contas reais, no job `core-api-migrate`, ANTES de o +// app subir: se ela falhar ou semear errado, o deploy cai ou a sequência nasce no lugar errado. +// +// Este arquivo executa o MESMO `INSERT … SELECT` da migration sobre dados semeados, e cobra a regra +// que não pode se perder: cada convênio nasce em `MAX(next_nsa)` das contas dele. +// +// GATE: só roda com `MYSQL_INTEGRATION=1`. + +import { describe, it, before, beforeEach, after } from 'node:test'; +import { strict as assert } from 'node:assert'; +import process from 'node:process'; +import { eq, like, sql } from 'drizzle-orm'; + +import { openMysqlFinancial } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; +import type { FinancialMysqlHandle } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; +import { + finCedenteAccounts, + finConvenioNsa, +} from '#src/modules/financial/adapters/persistence/schemas/mysql.ts'; +import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; +import { mysqlTestConnectionString } from '#tests/support/mysql-conn.ts'; + +// Espaço de chave PRÓPRIO, limpo na entrada — agência e prefixo de convênio, como os arquivos irmãos. +const OWN_AGENCY = '4323'; +const OWN_CONVENIO_PREFIX = '93'; + +let seq = 0; + +if (!process.env['MYSQL_INTEGRATION']) { + process.stdout.write( + '[financial:convenio-nsa-backfill] MYSQL_INTEGRATION não definido — pulando.\n', + ); +} else { + const connectionString = + process.env['FINANCIAL_DATABASE_URL'] ?? + process.env['CONTRACTS_DATABASE_URL'] ?? + mysqlTestConnectionString(); + + describe('Backfill de fin_convenio_nsa — migration 0057 (#943, CA4)', () => { + let handle: FinancialMysqlHandle; + + before(async () => { + const r = await openMysqlFinancial({ connectionString, applyMigrations: true, poolLimit: 4 }); + if (!r.ok) throw new Error(`[financial:convenio-nsa-backfill] Falha ao conectar: ${r.error}`); + handle = r.value; + }); + + beforeEach(async () => { + await handle.db.delete(finCedenteAccounts).where(eq(finCedenteAccounts.agency, OWN_AGENCY)); + await handle.db + .delete(finConvenioNsa) + .where(like(finConvenioNsa.convenio, `${OWN_CONVENIO_PREFIX}%`)); + }); + + after(async () => { + await handle?.close(); + }); + + // Insere direto na tabela, sem passar pelo domínio: o backfill roda sobre o que ESTÁ no banco, + // inclusive estado que o construtor de hoje não produziria. + const seedAccount = async ( + convenio: string, + nextNsa: number, + status: 'Active' | 'Closed' = 'Active', + ): Promise => { + seq += 1; + await handle.db.insert(finCedenteAccounts).values({ + id: CedenteAccountId.generate(), + bankCode: '237', + agency: OWN_AGENCY, + accountNumber: `7100${String(seq).padStart(2, '0')}`, + accountDigit: '3', + convenio, + document: '12345678000190', + status, + nextNsa, + }); + }; + + // O MESMO statement da migration `0057_lying_vindicator.sql`, com UMA diferença declarada: o + // `AND convenio LIKE '93%'`. + // + // ⚠️ POR QUE O RECORTE EXISTE, e por que ele não enfraquece o teste: a migration roda sobre a + // tabela INTEIRA, uma vez, contra `fin_convenio_nsa` vazia. Aqui, `fin_cedente_accounts` tem + // também as contas dos arquivos irmãos (agências 4321/9001), cujos convênios já ganharam linha + // pelas alocações deles — reinseri-los colidiria na PK e derrubaria o statement por um motivo + // que não é o que este arquivo mede. O recorte isola o espaço de chave, como o `beforeEach` faz. + // + // O que o teste mede continua sendo o statement de produção: `MAX`, `TRIM`, `GROUP BY` e o + // `ON DUPLICATE KEY UPDATE … GREATEST`. Se a regra mudar na migration e não aqui, este arquivo + // passa a aprovar algo que não roda — venha alterar os dois juntos. + const runBackfill = async (): Promise => { + await handle.db.execute( + sql`INSERT INTO fin_convenio_nsa (convenio, next_nsa) + SELECT TRIM(convenio), MAX(next_nsa) + FROM fin_cedente_accounts + WHERE TRIM(convenio) <> '' + AND TRIM(convenio) LIKE ${`${OWN_CONVENIO_PREFIX}%`} + GROUP BY TRIM(convenio) + ON DUPLICATE KEY UPDATE next_nsa = GREATEST(fin_convenio_nsa.next_nsa, VALUES(next_nsa))`, + ); + }; + + const sequenceOf = async (convenio: string): Promise => { + const rows = await handle.db + .select() + .from(finConvenioNsa) + .where(eq(finConvenioNsa.convenio, convenio)) + .limit(1); + return rows[0]?.nextNsa ?? null; + }; + + /* + * ⚠️ O CASO QUE A ISSUE DESCREVE COM NÚMEROS, e o que ele impede: + * + * convênio X, conta A em 57 e conta B em 12. A emitiu 1–56; B reemitiu 1–11 (o defeito). + * Começar em 57 não colide com nada. Começar em 12 reemitiria 12–56. + * + * E a reemissão não é só semântica: `fin_remittance_payables.your_number` é + * `` com UNIQUE global, e as referências antigas continuam gravadas. + * Um contador que retrocede bate no índice contra linhas históricas. + */ + it('CA4: a sequência nasce no MAIOR next_nsa das contas do convênio', async () => { + const convenio = `${OWN_CONVENIO_PREFIX}0001`; + await seedAccount(convenio, 57); + await seedAccount(convenio, 12); + + await runBackfill(); + + assert.equal( + await sequenceOf(convenio), + 57, + 'a sequência nasceu abaixo de um número já emitido', + ); + }); + + // Contas `Closed` ENTRAM no agrupamento: os números que elas gastaram existem no banco, e + // ignorá-las reabriria faixa já usada. É o caso que um `WHERE status = 'Active'` quebraria. + it('CA4: conta ENCERRADA conta para o máximo — os números que ela gastou existem', async () => { + const convenio = `${OWN_CONVENIO_PREFIX}0002`; + await seedAccount(convenio, 3); + await seedAccount(convenio, 91, 'Closed'); + + await runBackfill(); + + assert.equal( + await sequenceOf(convenio), + 91, + 'a conta encerrada foi ignorada e a faixa reabriu', + ); + }); + + it('CA4: convênios distintos ganham linhas independentes', async () => { + const here = `${OWN_CONVENIO_PREFIX}0003`; + const there = `${OWN_CONVENIO_PREFIX}0004`; + await seedAccount(here, 8); + await seedAccount(there, 2); + + await runBackfill(); + + assert.equal(await sequenceOf(here), 8); + assert.equal(await sequenceOf(there), 2); + }); + + // Convênio vazio fica de fora: sem convênio não há contrato a que a sequência pertença, e a PK + // não aceita a string vazia como identidade. A conta já é recusada antes do NSA por + // `checkCedenteConvenio` (`cedente-convenio-missing`). + it('CA4: conta SEM convênio não cria linha — e não derruba o backfill', async () => { + await seedAccount('', 5); + await seedAccount(' ', 9); + + await runBackfill(); + + assert.equal(await sequenceOf(''), null); + assert.equal(await sequenceOf(' '), null); + }); + + // ⚠️ O `TRIM` dos dois lados é o que impede o backfill de ABORTAR. O convênio é texto digitado no + // cadastro; ` 930005` e `930005` são o mesmo contrato, e sem o `TRIM` no `GROUP BY` eles viram + // dois grupos que colidem na mesma PK depois do `TRIM` do `SELECT` — `ER_DUP_ENTRY`, migration + // falhada, e o deploy cai ANTES de o app subir, porque o job de migration roda primeiro. + it('CA4: convênios que só diferem por espaço são UM contrato — a migration não aborta', async () => { + const convenio = `${OWN_CONVENIO_PREFIX}0005`; + await seedAccount(convenio, 4); + await seedAccount(` ${convenio}`, 19); + await seedAccount(`${convenio} `, 7); + + await runBackfill(); + + assert.equal(await sequenceOf(convenio), 19, 'o máximo ignorou as variantes com espaço'); + }); + + /* + * ⚠️ IDEMPOTÊNCIA — e ela deixou de ser luxo por causa da #996. + * + * A migration roda uma vez pelo journal, mas o job que a executa vive num pipeline de deploy que + * está falhando e pode ser retentado. Um `INSERT` cru abortaria com `ER_DUP_ENTRY` na segunda + * passada e derrubaria o deploy ANTES de o app subir — trocando um deploy que falha por um + * deploy que falha PIOR, com o banco já alterado. + * + * O `GREATEST` resolve os dois lados: reexecutar não aborta, e não faz o contador RETROCEDER. + * O segundo é o que importa de verdade — entre a primeira execução e a retentativa, contas podem + * ter emitido, e um backfill que sobrescrevesse com o `MAX` antigo reemitiria faixa usada. + */ + it('CA4: reexecutar o backfill não aborta e NUNCA faz o contador retroceder', async () => { + const convenio = `${OWN_CONVENIO_PREFIX}0006`; + await seedAccount(convenio, 30); + + await runBackfill(); + assert.equal(await sequenceOf(convenio), 30); + + // A sequência avançou entre a primeira passada e a retentativa — como aconteceria se o app + // tivesse subido e emitido antes de alguém reexecutar o job. + await handle.db + .update(finConvenioNsa) + .set({ nextNsa: 44 }) + .where(eq(finConvenioNsa.convenio, convenio)); + + await runBackfill(); + + assert.equal( + await sequenceOf(convenio), + 44, + 'a reexecução sobrescreveu com o MAX antigo e reabriu faixa já emitida', + ); + }); + }); +} From c3d243171cce8ab19a81b9ca0af815123fe0cef6 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 16:14:08 -0300 Subject: [PATCH 08/13] =?UTF-8?q?fix(financial):=20o=20conv=C3=AAnio=20vol?= =?UTF-8?q?ta=20a=20ser=20edit=C3=A1vel=20na=20conta=20encerrada=20(#995?= =?UTF-8?q?=20B8.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_._.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 --- .../use-cases/edit-cedente-account.ts | 14 ++++- .../use-cases/edit-cedente-account.test.ts | 57 ++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/modules/financial/application/use-cases/edit-cedente-account.ts b/src/modules/financial/application/use-cases/edit-cedente-account.ts index 96e79646..57ae2f0b 100644 --- a/src/modules/financial/application/use-cases/edit-cedente-account.ts +++ b/src/modules/financial/application/use-cases/edit-cedente-account.ts @@ -1,6 +1,7 @@ import { type Result, ok, err } from '../../../../shared/primitives/result.ts'; import * as CedenteAccountId from '../../domain/cedente/cedente-account-id.ts'; import { checkCedenteConvenio } from '../../domain/cedente/remittance-eligibility.ts'; +import { isActive } from '../../domain/cedente/cedente-account.ts'; import type { AccountType, CedenteAccount } from '../../domain/cedente/types.ts'; import type { CedenteAccountIdError } from '../../domain/cedente/cedente-account-id.ts'; import type { @@ -115,10 +116,21 @@ export const editCedenteAccount = // O efeito é o correto nos dois sentidos: convênio malformado ou longo demais passa a ACEITAR // correção pela tela (é o que o operador precisa), e convênio válido continua RECUSANDO a troca // — o invariante do #722 não afrouxa. + // ⚠️ A TRAVA VALE SÓ EM CONTA ATIVA (#995, B8.1), e a razão é a mesma que a criou. O #722 travou + // a troca porque o convênio viaja no NOME de toda remessa transmitida: reescrevê-lo faria as + // remessas antigas apontarem para um contrato que a conta não declara mais. + // + // Em conta ENCERRADA não há remessa nova a nomear. Travar o campo ali não protege coisa alguma — + // só força `UPDATE` direto no banco de produção, que foi o que aconteceu em 06/09 e é o tipo de + // intervenção que a #879 já mostrou custar caro. O histórico das remessas antigas continua + // intacto: elas guardam o próprio nome de arquivo em `fin_remittances`, não uma referência viva + // ao cadastro. + // + // O invariante do #722 não afrouxa onde ele importa: conta ativa segue recusando a troca. const wantsConvenioChange = input.convenio !== undefined && input.convenio.trim() !== found.value.convenio.trim(); const currentConvenioIsUsable = checkCedenteConvenio(found.value).ok; - if (wantsConvenioChange && currentConvenioIsUsable) { + if (wantsConvenioChange && currentConvenioIsUsable && isActive(found.value)) { return err('cedente-convenio-already-set'); } diff --git a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts index a4815c71..489c22ae 100644 --- a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts +++ b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts @@ -3,7 +3,10 @@ import { strict as assert } from 'node:assert'; import { type Result, ok } from '#src/shared/index.ts'; import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; -import { create as createCedente } from '#src/modules/financial/domain/cedente/cedente-account.ts'; +import { + create as createCedente, + close as closeCedente, +} from '#src/modules/financial/domain/cedente/cedente-account.ts'; import { checkCedenteConvenio } from '#src/modules/financial/domain/cedente/remittance-eligibility.ts'; // W0 RED (019): o use-case editCedenteAccount ainda não existe. import { editCedenteAccount } from '#src/modules/financial/application/use-cases/edit-cedente-account.ts'; @@ -148,6 +151,58 @@ describe('edit-cedente-account — o convênio preenche, mas não troca (#722/#8 }); }); +/** + * O CONVÊNIO VOLTA A SER EDITÁVEL NA CONTA ENCERRADA (#995, B8.1). + * + * A trava do #722 existe porque o convênio viaja no NOME de toda remessa transmitida — reescrevê-lo + * numa conta ATIVA faria as remessas antigas apontarem para um contrato que a conta não declara + * mais. Em conta ENCERRADA não há remessa nova a nomear, e travar ali não protege nada: só força + * `UPDATE` direto no banco de produção, que foi o que aconteceu em 06/09. + */ +describe('edit-cedente-account — o convênio na conta encerrada (#995 B8.1)', () => { + const editConvenio = async (account: unknown, convenio: string) => + editCedenteAccount(deps(account, false) as never)({ + id: String((account as { id: unknown }).id), + convenio, + }); + + const closedAccount = (convenio: string) => { + const closed = closeCedente(buildAccount(convenio)); + if (!closed.ok) throw new Error('setup: close'); + return closed.value; + }; + + it('conta ENCERRADA aceita trocar um convênio válido — a trava do #722 não a alcança', async () => { + const account = closedAccount('123456'); + const r = await editConvenio(account, '654321'); + + assert.equal(r.ok, true, 'conta encerrada não tem remessa nova a nomear'); + if (r.ok) assert.equal(r.value.convenio, '654321'); + }); + + // O outro lado, e é o invariante que NÃO pode afrouxar: em conta ativa a recusa continua. + it('conta ATIVA com convênio válido continua recusando a troca', async () => { + const account = buildAccount('123456'); + const r = await editConvenio(account, '654321'); + + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'cedente-convenio-already-set'); + }); + + // ⚠️ A trava FR-008 é OUTRA e continua valendo: ela olha dado bancário, não convênio. Uma conta + // encerrada COM histórico não vira porta aberta para reescrever agência ou conta. + it('encerrada não vira passe livre: dado bancário segue travado por histórico', async () => { + const account = closedAccount('123456'); + const r = await editCedenteAccount(deps(account, true) as never)({ + id: String(account.id), + agency: '4321', + }); + + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'cedente-account-bank-data-locked'); + }); +}); + /** * O DV DA AGÊNCIA na edição (#856 · #942/#943). * From c9dc20e12681e28fd253a753a8080a83983a71dd Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 16:20:45 -0300 Subject: [PATCH 09/13] =?UTF-8?q?fix(financial):=20o=20conv=C3=AAnio=20VAZ?= =?UTF-8?q?IO=20desativa=20a=20numera=C3=A7=C3=A3o=20da=20conta=20encerrad?= =?UTF-8?q?a=20(#995=20B8.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../financial/adapters/http/schemas.ts | 16 ++++++++- .../http/financial-cedente.http.test.ts | 33 ++++++++++++++++++ .../use-cases/edit-cedente-account.test.ts | 34 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/modules/financial/adapters/http/schemas.ts b/src/modules/financial/adapters/http/schemas.ts index d80c0b47..796a5b49 100644 --- a/src/modules/financial/adapters/http/schemas.ts +++ b/src/modules/financial/adapters/http/schemas.ts @@ -875,7 +875,21 @@ export type CreateCedenteAccountBody = z.infer { assert.equal((patched.json() as { agencyDigit: string | null }).agencyDigit, '7'); }); + /* + * B8.2 (#995) — a borda passa a ACEITAR o convênio vazio no PATCH. + * + * ⚠️ ESTE CASO MEDE A BORDA, e é onde o defeito estava: o use case sempre soube lidar com vazio + * (`checkCedenteConvenio` o recusa com nome próprio), mas o schema declarava `min(1)` e o pedido + * morria em 400 antes de chegar lá. Um teste só do use case ficaria verde com a operação ainda + * impossível pela tela — que é exatamente o que empurrou a correção de 06/09 para `UPDATE` no + * banco de produção. + */ + it('#995 B8.2: o PATCH aceita convênio VAZIO — 400 aqui significa o `min(1)` de volta', async () => { + const created = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ accountNumber: '991122', nickname: 'Conta a desativar' }), + }); + assert.equal(created.statusCode, 201, created.body); + const id = (created.json() as { id: string }).id; + + const patched = await handle.app.inject({ + method: 'PATCH', + url: `/api/v2/financial/cedente-accounts/${id}`, + headers: { authorization: `Bearer ${WRITER}` }, + payload: { convenio: '' }, + }); + + assert.notEqual( + patched.statusCode, + 400, + 'o schema recusou a string vazia — o `min(1)` voltou ao `editCedenteAccountBodySchema`', + ); + }); + it('CA-US2: POST /cedente-accounts/:id/close → rota existe (≠ 404)', async () => { const id = '11111111-1111-4111-8111-111111111111'; const res = await handle.app.inject({ diff --git a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts index 489c22ae..d52a4973 100644 --- a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts +++ b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts @@ -189,6 +189,40 @@ describe('edit-cedente-account — o convênio na conta encerrada (#995 B8.1)', if (!r.ok) assert.equal(r.error, 'cedente-convenio-already-set'); }); + /* + * ⚠️ B8.2 — LIMPAR o convênio é o que DESATIVA a numeração da linha morta. + * + * Vazio foi escolhido no lugar de `000000` porque já significa isso em todo o caminho: + * `checkCedenteConvenio` o recusa com `cedente-convenio-missing` ANTES do `allocateNsa` — e é essa + * ordem que importa, porque 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, e a recusa + * viria do BANCO, depois do NSA queimado. Mesmo padrão da #942. + */ + it('B8.2: limpar o convênio da conta encerrada desativa a numeração dela', async () => { + const account = closedAccount('123456'); + const r = await editConvenio(account, ''); + + assert.equal(r.ok, true, 'a borda ou o use case ainda barram limpar o campo'); + if (r.ok) { + assert.equal(r.value.convenio, ''); + // A prova de que "desativou": a régua da remessa passa a recusar, e com nome próprio. + const readiness = checkCedenteConvenio(r.value); + assert.equal(readiness.ok, false); + if (!readiness.ok) assert.equal(readiness.error, 'cedente-convenio-missing'); + } + }); + + // O contraponto que impede o B8.2 de virar buraco: limpar NÃO é privilégio de conta encerrada por + // acaso — em conta ATIVA com convênio válido, limpar é uma TROCA, e a trava do #722 a recusa. + // Sem este caso, alguém poderia desativar a numeração da conta que está pagando. + it('B8.2: conta ATIVA não pode ser desativada pela limpeza do convênio', async () => { + const account = buildAccount('123456'); + const r = await editConvenio(account, ''); + + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'cedente-convenio-already-set'); + }); + // ⚠️ A trava FR-008 é OUTRA e continua valendo: ela olha dado bancário, não convênio. Uma conta // encerrada COM histórico não vira porta aberta para reescrever agência ou conta. it('encerrada não vira passe livre: dado bancário segue travado por histórico', async () => { From d35de7e199cd6033877d513aaace93144eaad06b Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 16:45:44 -0300 Subject: [PATCH 10/13] =?UTF-8?q?feat(financial):=20reabrir=20e=20excluir?= =?UTF-8?q?=20conta-cedente=20=E2=80=94=20o=20encerramento=20deixa=20de=20?= =?UTF-8?q?ser=20beco=20(#995=20B1/B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../financial/adapters/http/composition.ts | 7 + .../financial/adapters/http/error-mapping.ts | 19 +- src/modules/financial/adapters/http/plugin.ts | 40 + .../mappers/cedente-account.mapper.ts | 14 +- .../mysql/0058_melodic_nick_fury.sql | 31 + .../migrations/mysql/meta/0058_snapshot.json | 2834 +++++++++++++++++ .../migrations/mysql/meta/_journal.json | 7 + .../repos/cedente-account-store.drizzle.ts | 18 +- .../repos/cedente-account-store.in-memory.ts | 7 +- .../adapters/persistence/schemas/mysql.ts | 37 +- .../use-cases/delete-cedente-account.ts | 58 + .../use-cases/reopen-cedente-account.ts | 51 + .../domain/cedente/cedente-account.ts | 41 + src/modules/financial/domain/cedente/types.ts | 21 +- .../http/financial-cedente.http.test.ts | 123 + .../mappers/cedente-account.mapper.test.ts | 3 + .../cedente/cedente-account-lifecycle.test.ts | 154 + 17 files changed, 3456 insertions(+), 9 deletions(-) create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/0058_melodic_nick_fury.sql create mode 100644 src/modules/financial/adapters/persistence/migrations/mysql/meta/0058_snapshot.json create mode 100644 src/modules/financial/application/use-cases/delete-cedente-account.ts create mode 100644 src/modules/financial/application/use-cases/reopen-cedente-account.ts create mode 100644 tests/modules/financial/domain/cedente/cedente-account-lifecycle.test.ts diff --git a/src/modules/financial/adapters/http/composition.ts b/src/modules/financial/adapters/http/composition.ts index 24bd983d..0991a8c0 100644 --- a/src/modules/financial/adapters/http/composition.ts +++ b/src/modules/financial/adapters/http/composition.ts @@ -227,6 +227,9 @@ import { createCedenteAccount } from '../../application/use-cases/create-cedente import { listCedenteAccounts } from '../../application/use-cases/list-cedente-accounts.ts'; import { listCedenteAccountsWithBalance } from '../../application/use-cases/list-cedente-accounts-with-balance.ts'; import { closeCedenteAccount } from '../../application/use-cases/close-cedente-account.ts'; +// #995 B1/B3 — desfazer o encerramento e excluir (soft delete). Irmãos do close. +import { reopenCedenteAccount } from '../../application/use-cases/reopen-cedente-account.ts'; +import { deleteCedenteAccount } from '../../application/use-cases/delete-cedente-account.ts'; import { editCedenteAccount } from '../../application/use-cases/edit-cedente-account.ts'; import { getAccountStatement } from '../../application/use-cases/get-account-statement.ts'; import { getTransactionReconciliation } from '../../application/use-cases/get-transaction-reconciliation.ts'; @@ -372,6 +375,8 @@ export type FinancialHttpDeps = Readonly<{ findCedenteAccountById: CedenteAccountStore['findById']; /** Conta-cedente (019) — POST /cedente-accounts/:id/close. */ closeCedenteAccount: ReturnType; + reopenCedenteAccount: ReturnType; + deleteCedenteAccount: ReturnType; /** Conta-cedente (019) — PATCH /cedente-accounts/:id. */ editCedenteAccount: ReturnType; /** Read-model do extrato (#139) — GET /cedente-accounts/:id/statement. */ @@ -1129,6 +1134,8 @@ const makeDeps = (pools: Pools, clock: Clock = ClockReal()): FinancialHttpDeps = }), findCedenteAccountById: pools.cedenteStore.findById, closeCedenteAccount: closeCedenteAccount({ cedenteStore: pools.cedenteStore }), + reopenCedenteAccount: reopenCedenteAccount({ cedenteStore: pools.cedenteStore }), + deleteCedenteAccount: deleteCedenteAccount({ cedenteStore: pools.cedenteStore }), editCedenteAccount: editCedenteAccount({ cedenteStore: pools.cedenteStore, accountHistory: createStatementBackedAccountHistory(pools.statementRepo), diff --git a/src/modules/financial/adapters/http/error-mapping.ts b/src/modules/financial/adapters/http/error-mapping.ts index 68351a51..425fa48f 100644 --- a/src/modules/financial/adapters/http/error-mapping.ts +++ b/src/modules/financial/adapters/http/error-mapping.ts @@ -72,6 +72,11 @@ const CONFLICT_CODES: ReadonlySet = new Set([ 'cedente-account-already-closed', 'cedente-account-duplicate', 'cedente-account-bank-data-locked', + // #995 B2/B3 — conflitos de ESTADO na máquina Active/Closed/Deleted. 409 e não 422: o pedido está + // bem-formado; o que não serve é o estado em que a conta está. + 'cedente-account-not-closed', + 'cedente-account-not-closed-for-delete', + 'cedente-account-already-deleted', // #722: convênio preenche uma vez. Trocar é conflito com o estado — ele identifica o contrato no // banco e viaja no nome de toda remessa já transmitida. 'cedente-convenio-already-set', @@ -331,9 +336,21 @@ const SLUG_MESSAGES: Record = { // #293: não se lança pagamento contra conta encerrada. 'cedente-account-closed': 'A conta-cedente está encerrada e não pode ser usada para pagamento.', // Conta-cedente (019 — CRUD). + // ⚠️ #995 B7 — a mensagem DIZ que a conta existente pode estar encerrada, e diz o que fazer. + // Antes ela afirmava só "já existe", e quem lia não tinha como saber: a conta não aparecia no + // grid ativo, o recadastro era recusado, e não havia caminho visível. Foi assim que um + // encerramento por engano virou beco em produção (06/09) e terminou em `UPDATE` no banco. 'cedente-account-duplicate': - 'Já existe uma conta-cedente com esta chave bancária (banco/agência/conta/dígito).', + 'Já existe uma conta-cedente com esta chave bancária (banco/agência/conta/dígito) — e ela pode estar ENCERRADA. Localize-a no filtro de encerradas e escolha reabrir, se quiser voltar a usá-la, ou excluir, para liberar estes dados para um cadastro novo.', 'cedente-account-already-closed': 'A conta-cedente já está encerrada.', + // #995 B2/B3 — as três recusas da máquina de estados. Cada uma termina numa AÇÃO diferente, e é a + // ação que o operador precisa ler; achatá-las numa frase só não diria o que fazer em nenhuma. + 'cedente-account-not-closed': + 'Esta conta-cedente não está encerrada, então não há o que reabrir. Se ela foi excluída, não há como voltar atrás — cadastre-a novamente.', + 'cedente-account-not-closed-for-delete': + 'Só é possível excluir uma conta-cedente já encerrada. Encerre-a primeiro — a exclusão não tem volta, e por isso exige os dois passos.', + 'cedente-account-already-deleted': + 'Esta conta-cedente já foi excluída. O histórico dela continua disponível, mas ela não volta ao cadastro — para usar estes dados bancários de novo, faça um cadastro novo.', 'cedente-account-bank-data-locked': 'A conta-cedente já tem histórico: os dados bancários não podem mais ser alterados.', 'invalid-account-type': 'Tipo de conta inválido (esperado: corrente, poupanca ou investimento).', diff --git a/src/modules/financial/adapters/http/plugin.ts b/src/modules/financial/adapters/http/plugin.ts index 08796b04..228597ac 100644 --- a/src/modules/financial/adapters/http/plugin.ts +++ b/src/modules/financial/adapters/http/plugin.ts @@ -2336,6 +2336,46 @@ const financialRoutes = return sendResult(reply, ok(cedenteAccountToDto(result.value)), { ok: 200 }); }, }); + + // POST /financial/cedente-accounts/:id/reopen — desfaz o encerramento (#995 B1). + // + // ⚠️ POST e não PATCH: é uma TRANSIÇÃO de estado nomeada, irmã do `close`, e não a edição de um + // campo. O `PATCH` do recurso segue sem aceitar `status` — o que impede que reabrir vire efeito + // colateral de quem só queria trocar o apelido. + scope.route({ + method: 'POST', + url: '/financial/cedente-accounts/:id/reopen', + preHandler: [hooks.requireAuth, hooks.authorize(FINANCIAL_PERMISSION.bankAccountWrite)], + schema: { + params: cedenteAccountIdParamSchema, + response: { 200: cedenteAccountResponseSchema }, + } satisfies FastifyZodOpenApiSchema, + handler: async (req, reply) => { + const result = await deps.reopenCedenteAccount({ id: req.params.id }); + if (!result.ok) return sendDomainError(reply, result.error); + return sendResult(reply, ok(cedenteAccountToDto(result.value)), { ok: 200 }); + }, + }); + + // DELETE /financial/cedente-accounts/:id — exclui (#995 B3). SOFT delete: a linha permanece. + // + // ⚠️ Responde 200 com o recurso, e não 204: a conta continua EXISTINDO (é soft delete), e o + // corpo é o que permite ao front confirmar o novo `status` sem uma segunda chamada. Um 204 diria + // "não há mais nada aqui", que é falso — o histórico segue alcançável por este mesmo id. + scope.route({ + method: 'DELETE', + url: '/financial/cedente-accounts/:id', + preHandler: [hooks.requireAuth, hooks.authorize(FINANCIAL_PERMISSION.bankAccountWrite)], + schema: { + params: cedenteAccountIdParamSchema, + response: { 200: cedenteAccountResponseSchema }, + } satisfies FastifyZodOpenApiSchema, + handler: async (req, reply) => { + const result = await deps.deleteCedenteAccount({ id: req.params.id }); + if (!result.ok) return sendDomainError(reply, result.error); + return sendResult(reply, ok(cedenteAccountToDto(result.value)), { ok: 200 }); + }, + }); }; export const financialHttpPlugin = diff --git a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts index 5cd6251d..0b50be01 100644 --- a/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts +++ b/src/modules/financial/adapters/persistence/mappers/cedente-account.mapper.ts @@ -20,7 +20,18 @@ export type CedenteAccountMapperError = | 'invalid-cedente-account-type'; const toStatus = (raw: string): CedenteAccountStatus | null => - raw === 'Active' || raw === 'Closed' ? raw : null; + raw === 'Active' || raw === 'Closed' || raw === 'Deleted' ? raw : null; + +// O discriminador do soft delete na UNIQUE de chave natural (#995, B4). Ver `schemas/mysql.ts` para +// o mecanismo — aqui só a derivação, que existe EM UM LUGAR SÓ de propósito. +// +// ⚠️ Toda escrita da conta passa por `toRow`, então derivar aqui é o que garante que status e slot +// nunca divirjam. Uma linha `Deleted` com slot `'LIVE'` continuaria ocupando a chave para sempre — +// e o CHECK `fin_cedente_accounts_status_deleted_chk` é a rede que recusa exatamente isso, caso +// alguém escreva por outro caminho. +const LIVE_SLOT = 'LIVE'; +const naturalKeySlotOf = (account: CedenteAccount): string => + account.status === 'Deleted' ? String(account.id) : LIVE_SLOT; export const toRow = (account: CedenteAccount): NewCedenteAccountRow => ({ id: account.id, @@ -32,6 +43,7 @@ export const toRow = (account: CedenteAccount): NewCedenteAccountRow => ({ convenio: account.convenio, document: account.document, status: account.status, + naturalKeySlot: naturalKeySlotOf(account), nextNsa: account.nextNsa, type: account.type ?? null, typeLabel: account.typeLabel ?? null, diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/0058_melodic_nick_fury.sql b/src/modules/financial/adapters/persistence/migrations/mysql/0058_melodic_nick_fury.sql new file mode 100644 index 00000000..b7a89ed8 --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/0058_melodic_nick_fury.sql @@ -0,0 +1,31 @@ +-- Soft delete da conta-cedente (#995, B3/B4): status `Deleted` + o discriminador da chave natural. +-- +-- ⚠️ A ORDEM DOS SEIS STATEMENTS É O QUE TORNA ISTO SEGURO, e ela não é arbitrária: +-- +-- 1. dropa a UNIQUE antiga (4 colunas) — ela não pode coexistir com a nova sobre as mesmas +-- colunas mais uma; +-- 2. dropa o CHECK de status — vai voltar aceitando `Deleted`; +-- 3. ACRESCENTA `natural_key_slot` com `DEFAULT 'LIVE'`. É aqui que a migration se paga: toda +-- linha existente é `Active` ou `Closed`, e `'LIVE'` é exatamente o valor correto para ela. +-- Nenhum backfill, nenhuma linha a corrigir depois; +-- 4. recria a UNIQUE com a quinta coluna. Como todas as linhas existentes têm o mesmo `'LIVE'`, +-- a unicidade cobrada é IDÊNTICA à anterior — a migration não pode falhar por duplicata que +-- já não falhasse antes; +-- 5. o CHECK que amarra `Deleted` ao slot (ver `schemas/mysql.ts`); +-- 6. o CHECK de status, agora com os três valores. +-- +-- A janela entre 1 e 4 fica sem a garantia de unicidade. É aceitável porque a migration roda sozinha, +-- antes de o app subir (job `core-api-migrate`), e porque o passo 4 falharia em vez de deixar passar +-- duplicata — o risco seria de migration abortada, não de dado sujo. +-- +-- ⚠️ Por que `'LIVE'` e não NULL: em MySQL, linhas com NULL numa coluna do índice único NÃO contam +-- como duplicatas. Com NULL nas vivas, a unicidade do FR-016 sumiria em silêncio — o efeito INVERSO +-- do pretendido. O raciocínio completo está em `schemas/mysql.ts`, na declaração da UNIQUE. +-- +-- `COLLATE utf8mb4_bin` porque a coluna guarda um identificador: comparação de UUID é byte a byte. +ALTER TABLE `fin_cedente_accounts` DROP INDEX `fin_cedente_accounts_natural_key_uq`;--> statement-breakpoint +ALTER TABLE `fin_cedente_accounts` DROP CONSTRAINT `fin_cedente_accounts_status_chk`;--> statement-breakpoint +ALTER TABLE `fin_cedente_accounts` ADD `natural_key_slot` varchar(36) COLLATE utf8mb4_bin DEFAULT 'LIVE' NOT NULL;--> statement-breakpoint +ALTER TABLE `fin_cedente_accounts` ADD CONSTRAINT `fin_cedente_accounts_natural_key_uq` UNIQUE(`bank_code`,`agency`,`account_number`,`account_digit`,`natural_key_slot`);--> statement-breakpoint +ALTER TABLE `fin_cedente_accounts` ADD CONSTRAINT `fin_cedente_accounts_status_deleted_chk` CHECK (`fin_cedente_accounts`.`status` <> 'Deleted' OR `fin_cedente_accounts`.`natural_key_slot` = `fin_cedente_accounts`.`id`);--> statement-breakpoint +ALTER TABLE `fin_cedente_accounts` ADD CONSTRAINT `fin_cedente_accounts_status_chk` CHECK (`fin_cedente_accounts`.`status` IN ('Active','Closed','Deleted')); \ No newline at end of file diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/0058_snapshot.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0058_snapshot.json new file mode 100644 index 00000000..303db897 --- /dev/null +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/0058_snapshot.json @@ -0,0 +1,2834 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "4552e01f-fb7e-48d1-9d10-6bd7acf39ab1", + "prevId": "1a851617-87ed-4162-8bdf-1aa69651fdfa", + "tables": { + "fin_bank_statements": { + "name": "fin_bank_statements", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_format": { + "name": "file_format", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closing_balance_cents": { + "name": "closing_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_bank_statements_debit_account_ref_idx": { + "name": "fin_bank_statements_debit_account_ref_idx", + "columns": ["debit_account_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_bank_statements_id": { + "name": "fin_bank_statements_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_bank_statements_file_format_chk": { + "name": "fin_bank_statements_file_format_chk", + "value": "`fin_bank_statements`.`file_format` IN ('OFX','CSV','PDF')" + } + } + }, + "fin_categories": { + "name": "fin_categories", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "group": { + "name": "group", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "parent_id": { + "name": "parent_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_id": { + "name": "cost_center_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_categories_group_name_idx": { + "name": "fin_categories_group_name_idx", + "columns": ["group", "name"], + "isUnique": false + }, + "fin_categories_active_idx": { + "name": "fin_categories_active_idx", + "columns": ["active"], + "isUnique": false + }, + "fin_categories_parent_id_idx": { + "name": "fin_categories_parent_id_idx", + "columns": ["parent_id"], + "isUnique": false + }, + "fin_categories_cost_center_id_idx": { + "name": "fin_categories_cost_center_id_idx", + "columns": ["cost_center_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_categories_id": { + "name": "fin_categories_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_categories_group_chk": { + "name": "fin_categories_group_chk", + "value": "`fin_categories`.`group` IN ('despesa','receita','ajuste')" + } + } + }, + "fin_cedente_accounts": { + "name": "fin_cedente_accounts", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bank_code": { + "name": "bank_code", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agency_digit": { + "name": "agency_digit", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_number": { + "name": "account_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_digit": { + "name": "account_digit", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "convenio": { + "name": "convenio", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "natural_key_slot": { + "name": "natural_key_slot", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'LIVE'" + }, + "next_nsa": { + "name": "next_nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type_label": { + "name": "type_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bank_name": { + "name": "bank_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_cents": { + "name": "opening_balance_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opening_balance_date": { + "name": "opening_balance_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_cedente_accounts_natural_key_uq": { + "name": "fin_cedente_accounts_natural_key_uq", + "columns": ["bank_code", "agency", "account_number", "account_digit", "natural_key_slot"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cedente_accounts_id": { + "name": "fin_cedente_accounts_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_cedente_accounts_status_chk": { + "name": "fin_cedente_accounts_status_chk", + "value": "`fin_cedente_accounts`.`status` IN ('Active','Closed','Deleted')" + }, + "fin_cedente_accounts_next_nsa_chk": { + "name": "fin_cedente_accounts_next_nsa_chk", + "value": "`fin_cedente_accounts`.`next_nsa` >= 1" + }, + "fin_cedente_accounts_type_chk": { + "name": "fin_cedente_accounts_type_chk", + "value": "`fin_cedente_accounts`.`type` IS NULL OR `fin_cedente_accounts`.`type` IN ('corrente','poupanca','investimento','cartao','outro')" + }, + "fin_cedente_accounts_status_deleted_chk": { + "name": "fin_cedente_accounts_status_deleted_chk", + "value": "`fin_cedente_accounts`.`status` <> 'Deleted' OR `fin_cedente_accounts`.`natural_key_slot` = `fin_cedente_accounts`.`id`" + } + } + }, + "fin_convenio_nsa": { + "name": "fin_convenio_nsa", + "columns": { + "convenio": { + "name": "convenio", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "next_nsa": { + "name": "next_nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_convenio_nsa_convenio": { + "name": "fin_convenio_nsa_convenio", + "columns": ["convenio"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_convenio_nsa_next_nsa_chk": { + "name": "fin_convenio_nsa_next_nsa_chk", + "value": "`fin_convenio_nsa`.`next_nsa` >= 1" + } + } + }, + "fin_cost_centers": { + "name": "fin_cost_centers", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "fin_cost_centers_code_idx": { + "name": "fin_cost_centers_code_idx", + "columns": ["code"], + "isUnique": false + }, + "fin_cost_centers_active_idx": { + "name": "fin_cost_centers_active_idx", + "columns": ["active"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_cost_centers_id": { + "name": "fin_cost_centers_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_document_timeline": { + "name": "fin_document_timeline", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_kind": { + "name": "target_kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_ref": { + "name": "actor_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tl_doc_time": { + "name": "idx_fin_tl_doc_time", + "columns": ["document_id", "occurred_at"], + "isUnique": false + }, + "idx_fin_tl_target": { + "name": "idx_fin_tl_target", + "columns": ["target_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_document_timeline_document_id_fk": { + "name": "fin_document_timeline_document_id_fk", + "tableFrom": "fin_document_timeline", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_document_timeline_id": { + "name": "fin_document_timeline_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "ck_fin_tl_target_kind": { + "name": "ck_fin_tl_target_kind", + "value": "`fin_document_timeline`.`target_kind` IN ('Document','Payable')" + }, + "ck_fin_tl_event_type": { + "name": "ck_fin_tl_event_type", + "value": "`fin_document_timeline`.`event_type` IN ('DocumentSaved','PayableApproved','ApprovalUndone','DocumentDraftSaved','PayableManuallyPaid')" + } + } + }, + "fin_documents": { + "name": "fin_documents", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "series": { + "name": "series", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payee_kind": { + "name": "payee_kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gross_value": { + "name": "gross_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_discounts": { + "name": "source_discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "discounts": { + "name": "discounts", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "penalty": { + "name": "penalty", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "interest": { + "name": "interest", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "net_value": { + "name": "net_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_key": { + "name": "access_key", + "type": "varchar(44)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "competencia": { + "name": "competencia", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_bucket": { + "name": "source_file_bucket", + "type": "varchar(63)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_key": { + "name": "source_file_key", + "type": "varchar(1024) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_hash_sha256": { + "name": "source_file_hash_sha256", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_size_bytes": { + "name": "source_file_size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_file_mime": { + "name": "source_file_mime", + "type": "varchar(127)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_by_ocr": { + "name": "read_by_ocr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "ocr_original_value": { + "name": "ocr_original_value", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "divergence_detected": { + "name": "divergence_detected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "approved_at": { + "name": "approved_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approved_by": { + "name": "approved_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "approver_ref": { + "name": "approver_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_id": { + "name": "legacy_id", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_documents_supplier_ref_idx": { + "name": "fin_documents_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_documents_status_idx": { + "name": "fin_documents_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_documents_due_date_idx": { + "name": "fin_documents_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_documents_issue_date_idx": { + "name": "fin_documents_issue_date_idx", + "columns": ["issue_date"], + "isUnique": false + }, + "fin_documents_doc_number_idx": { + "name": "fin_documents_doc_number_idx", + "columns": ["document_number"], + "isUnique": false + }, + "fin_documents_legacy_id_uq": { + "name": "fin_documents_legacy_id_uq", + "columns": ["legacy_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_documents_id": { + "name": "fin_documents_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_documents_type_chk": { + "name": "fin_documents_type_chk", + "value": "`fin_documents`.`type` IS NULL OR `fin_documents`.`type` IN ('NFS-e','DANFE','RPA','Fatura','Boleto','Recibo','Imposto')" + }, + "fin_documents_payment_method_chk": { + "name": "fin_documents_payment_method_chk", + "value": "`fin_documents`.`payment_method` IS NULL OR `fin_documents`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_documents_payee_kind_chk": { + "name": "fin_documents_payee_kind_chk", + "value": "`fin_documents`.`payee_kind` IS NULL OR `fin_documents`.`payee_kind` IN ('supplier','financier','act','collaborator')" + }, + "fin_documents_status_chk": { + "name": "fin_documents_status_chk", + "value": "`fin_documents`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_documents_gross_value_chk": { + "name": "fin_documents_gross_value_chk", + "value": "`fin_documents`.`gross_value` IS NULL OR `fin_documents`.`gross_value` >= 0" + }, + "fin_documents_net_value_chk": { + "name": "fin_documents_net_value_chk", + "value": "`fin_documents`.`net_value` IS NULL OR `fin_documents`.`net_value` >= 0" + }, + "fin_documents_source_discounts_chk": { + "name": "fin_documents_source_discounts_chk", + "value": "`fin_documents`.`source_discounts` >= 0" + }, + "fin_documents_discounts_chk": { + "name": "fin_documents_discounts_chk", + "value": "`fin_documents`.`discounts` >= 0" + }, + "fin_documents_penalty_chk": { + "name": "fin_documents_penalty_chk", + "value": "`fin_documents`.`penalty` >= 0" + }, + "fin_documents_interest_chk": { + "name": "fin_documents_interest_chk", + "value": "`fin_documents`.`interest` >= 0" + }, + "fin_documents_version_chk": { + "name": "fin_documents_version_chk", + "value": "`fin_documents`.`version` >= 0" + }, + "fin_documents_source_file_all_or_none_chk": { + "name": "fin_documents_source_file_all_or_none_chk", + "value": "(`fin_documents`.`source_file_bucket` IS NULL AND `fin_documents`.`source_file_key` IS NULL AND `fin_documents`.`source_file_hash_sha256` IS NULL AND `fin_documents`.`source_file_size_bytes` IS NULL AND `fin_documents`.`source_file_mime` IS NULL) OR (`fin_documents`.`source_file_bucket` IS NOT NULL AND `fin_documents`.`source_file_key` IS NOT NULL AND `fin_documents`.`source_file_hash_sha256` IS NOT NULL AND `fin_documents`.`source_file_size_bytes` IS NOT NULL AND `fin_documents`.`source_file_mime` IS NOT NULL)" + }, + "fin_documents_source_file_size_bytes_chk": { + "name": "fin_documents_source_file_size_bytes_chk", + "value": "`fin_documents`.`source_file_size_bytes` IS NULL OR `fin_documents`.`source_file_size_bytes` > 0" + } + } + }, + "fin_expected_counterpart": { + "name": "fin_expected_counterpart", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_account_ref": { + "name": "origin_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_reconciliation_ref": { + "name": "origin_reconciliation_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_transaction_ref": { + "name": "origin_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_date": { + "name": "expected_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "matched_transaction_ref": { + "name": "matched_transaction_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_expected_counterpart_destination_status_idx": { + "name": "fin_expected_counterpart_destination_status_idx", + "columns": ["destination_account_ref", "status"], + "isUnique": false + }, + "fin_expected_counterpart_origin_reconciliation_idx": { + "name": "fin_expected_counterpart_origin_reconciliation_idx", + "columns": ["origin_reconciliation_ref"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_expected_counterpart_id": { + "name": "fin_expected_counterpart_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_expected_counterpart_type_chk": { + "name": "fin_expected_counterpart_type_chk", + "value": "`fin_expected_counterpart`.`type` IN ('Transfer','Investment','Redemption')" + }, + "fin_expected_counterpart_movement_chk": { + "name": "fin_expected_counterpart_movement_chk", + "value": "`fin_expected_counterpart`.`movement` IN ('Debit','Credit')" + }, + "fin_expected_counterpart_status_chk": { + "name": "fin_expected_counterpart_status_chk", + "value": "`fin_expected_counterpart`.`status` IN ('Pending','Matched','Discarded')" + }, + "fin_expected_counterpart_value_chk": { + "name": "fin_expected_counterpart_value_chk", + "value": "`fin_expected_counterpart`.`value_cents` > 0" + } + } + }, + "fin_manual_entries": { + "name": "fin_manual_entries", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "destination_account_ref": { + "name": "destination_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "product_label": { + "name": "product_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_number": { + "name": "document_number", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_type": { + "name": "document_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "issue_date": { + "name": "issue_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "document_value_cents": { + "name": "document_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_manual_entries_reconciliation_id_idx": { + "name": "fin_manual_entries_reconciliation_id_idx", + "columns": ["reconciliation_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_manual_entries_reconciliation_id_fk": { + "name": "fin_manual_entries_reconciliation_id_fk", + "tableFrom": "fin_manual_entries", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_manual_entries_id": { + "name": "fin_manual_entries_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_manual_entries_type_chk": { + "name": "fin_manual_entries_type_chk", + "value": "`fin_manual_entries`.`type` IN ('Payment','Receipt','Transfer','FeePenaltyInterest','Investment','Redemption')" + }, + "fin_manual_entries_value_chk": { + "name": "fin_manual_entries_value_chk", + "value": "`fin_manual_entries`.`value_cents` > 0" + } + } + }, + "fin_outbox": { + "name": "fin_outbox", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_processed_at_occurred_at_idx": { + "name": "fin_outbox_processed_at_occurred_at_idx", + "columns": ["processed_at", "occurred_at"], + "isUnique": false + }, + "fin_outbox_aggregate_id_idx": { + "name": "fin_outbox_aggregate_id_idx", + "columns": ["aggregate_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_event_id": { + "name": "fin_outbox_event_id", + "columns": ["event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_attempts_nonneg_chk": { + "name": "fin_outbox_attempts_nonneg_chk", + "value": "`fin_outbox`.`attempts` >= 0" + }, + "fin_outbox_event_type_nonempty_chk": { + "name": "fin_outbox_event_type_nonempty_chk", + "value": "CHAR_LENGTH(`fin_outbox`.`event_type`) > 0" + }, + "fin_outbox_aggregate_type_chk": { + "name": "fin_outbox_aggregate_type_chk", + "value": "`fin_outbox`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_outbox_dead_letter": { + "name": "fin_outbox_dead_letter", + "columns": { + "consumer_id": { + "name": "consumer_id", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aggregate_type": { + "name": "aggregate_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "varchar(8192)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_outbox_dl_failed_at_idx": { + "name": "fin_outbox_dl_failed_at_idx", + "columns": ["failed_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_outbox_dead_letter_consumer_id_event_id_pk": { + "name": "fin_outbox_dead_letter_consumer_id_event_id_pk", + "columns": ["consumer_id", "event_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_outbox_dl_attempts_nonneg_chk": { + "name": "fin_outbox_dl_attempts_nonneg_chk", + "value": "`fin_outbox_dead_letter`.`attempts` >= 0" + }, + "fin_outbox_dl_aggregate_type_chk": { + "name": "fin_outbox_dl_aggregate_type_chk", + "value": "`fin_outbox_dead_letter`.`aggregate_type` IN ('Document', 'Reconciliation', 'Statement', 'ReconciliationPeriod', 'ExpectedCounterpart', 'Remittance')" + } + } + }, + "fin_payable_view": { + "name": "fin_payable_view", + "columns": { + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contract_ref": { + "name": "contract_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_ref": { + "name": "category_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "budget_plan_ref": { + "name": "budget_plan_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subcategory_ref": { + "name": "subcategory_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_center_ref": { + "name": "cost_center_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "program_ref": { + "name": "program_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1970-01-01 00:00:00.000'" + } + }, + "indexes": { + "fin_payable_view_status_idx": { + "name": "fin_payable_view_status_idx", + "columns": ["status"], + "isUnique": false + }, + "fin_payable_view_cost_center_ref_idx": { + "name": "fin_payable_view_cost_center_ref_idx", + "columns": ["cost_center_ref"], + "isUnique": false + }, + "fin_payable_view_category_ref_idx": { + "name": "fin_payable_view_category_ref_idx", + "columns": ["category_ref"], + "isUnique": false + }, + "fin_payable_view_budget_plan_ref_idx": { + "name": "fin_payable_view_budget_plan_ref_idx", + "columns": ["budget_plan_ref"], + "isUnique": false + }, + "fin_payable_view_subcategory_ref_idx": { + "name": "fin_payable_view_subcategory_ref_idx", + "columns": ["subcategory_ref"], + "isUnique": false + }, + "fin_payable_view_program_ref_idx": { + "name": "fin_payable_view_program_ref_idx", + "columns": ["program_ref"], + "isUnique": false + }, + "fin_payable_view_supplier_ref_idx": { + "name": "fin_payable_view_supplier_ref_idx", + "columns": ["supplier_ref"], + "isUnique": false + }, + "fin_payable_view_due_date_idx": { + "name": "fin_payable_view_due_date_idx", + "columns": ["due_date"], + "isUnique": false + }, + "fin_payable_view_paid_at_idx": { + "name": "fin_payable_view_paid_at_idx", + "columns": ["paid_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_payable_view_payable_id": { + "name": "fin_payable_view_payable_id", + "columns": ["payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payable_view_kind_chk": { + "name": "fin_payable_view_kind_chk", + "value": "`fin_payable_view`.`kind` IN ('Parent','Child')" + }, + "fin_payable_view_status_chk": { + "name": "fin_payable_view_status_chk", + "value": "`fin_payable_view`.`status` IN ('Open','Approved','Transmitted','Paid','Cancelled')" + }, + "fin_payable_view_retention_type_chk": { + "name": "fin_payable_view_retention_type_chk", + "value": "`fin_payable_view`.`retention_type` IS NULL OR `fin_payable_view`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + } + } + }, + "fin_payables": { + "name": "fin_payables", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_type": { + "name": "retention_type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payment_detail": { + "name": "payment_detail", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_payables_document_id_idx": { + "name": "fin_payables_document_id_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_payables_status_idx": { + "name": "fin_payables_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_payables_document_id_fk": { + "name": "fin_payables_document_id_fk", + "tableFrom": "fin_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_payables_id": { + "name": "fin_payables_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_payables_kind_chk": { + "name": "fin_payables_kind_chk", + "value": "`fin_payables`.`kind` IN ('Parent','Child')" + }, + "fin_payables_retention_type_chk": { + "name": "fin_payables_retention_type_chk", + "value": "`fin_payables`.`retention_type` IS NULL OR `fin_payables`.`retention_type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_payables_status_chk": { + "name": "fin_payables_status_chk", + "value": "`fin_payables`.`status` IN ('Draft','Open','Approved','Transmitted','Refused','Paid','PartiallyReconciled','Reconciled')" + }, + "fin_payables_payment_method_chk": { + "name": "fin_payables_payment_method_chk", + "value": "`fin_payables`.`payment_method` IN ('TED','TransferenciaBancaria','PIX','Boleto','CartaoCorporativo','Cambio','GuiaRecolhimento','Outro')" + }, + "fin_payables_value_chk": { + "name": "fin_payables_value_chk", + "value": "`fin_payables`.`value` >= 0" + }, + "fin_payables_child_retention_chk": { + "name": "fin_payables_child_retention_chk", + "value": "(`fin_payables`.`kind` = 'Child') = (`fin_payables`.`retention_type` IS NOT NULL)" + }, + "fin_payables_paid_at_chk": { + "name": "fin_payables_paid_at_chk", + "value": "`fin_payables`.`status` <> 'Paid' OR `fin_payables`.`paid_at` IS NOT NULL" + } + } + }, + "fin_reconciliation_items": { + "name": "fin_reconciliation_items", + "columns": { + "reconciliation_id": { + "name": "reconciliation_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_value_cents": { + "name": "reconciled_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_items_payable_id_idx": { + "name": "fin_reconciliation_items_payable_id_idx", + "columns": ["payable_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_reconciliation_items_reconciliation_id_fk": { + "name": "fin_reconciliation_items_reconciliation_id_fk", + "tableFrom": "fin_reconciliation_items", + "tableTo": "fin_reconciliations", + "columnsFrom": ["reconciliation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_reconciliation_items_reconciliation_id_payable_id_pk": { + "name": "fin_reconciliation_items_reconciliation_id_payable_id_pk", + "columns": ["reconciliation_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_reconciliation_periods": { + "name": "fin_reconciliation_periods", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_end": { + "name": "period_end", + "type": "date", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed_at": { + "name": "closed_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closed_by": { + "name": "closed_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliation_periods_account_range_uq": { + "name": "fin_reconciliation_periods_account_range_uq", + "columns": ["debit_account_ref", "period_start", "period_end"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliation_periods_id": { + "name": "fin_reconciliation_periods_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliation_periods_status_chk": { + "name": "fin_reconciliation_periods_status_chk", + "value": "`fin_reconciliation_periods`.`status` IN ('Open','Closed')" + } + } + }, + "fin_reconciliations": { + "name": "fin_reconciliations", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difference_value_cents": { + "name": "difference_value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "difference_treatment": { + "name": "difference_treatment", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciled_by": { + "name": "reconciled_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "undone_at": { + "name": "undone_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undone_by": { + "name": "undone_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "undo_reason": { + "name": "undo_reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_reconciliations_transaction_id_idx": { + "name": "fin_reconciliations_transaction_id_idx", + "columns": ["transaction_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_reconciliations_id": { + "name": "fin_reconciliations_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_reconciliations_type_chk": { + "name": "fin_reconciliations_type_chk", + "value": "`fin_reconciliations`.`type` IN ('Individual','Multiple','Partial','ManualEntry')" + }, + "fin_reconciliations_status_chk": { + "name": "fin_reconciliations_status_chk", + "value": "`fin_reconciliations`.`status` IN ('Active','Undone')" + }, + "fin_reconciliations_difference_chk": { + "name": "fin_reconciliations_difference_chk", + "value": "(`fin_reconciliations`.`difference_value_cents` IS NULL AND `fin_reconciliations`.`difference_treatment` IS NULL) OR (`fin_reconciliations`.`difference_value_cents` IS NOT NULL AND `fin_reconciliations`.`difference_treatment` IN ('Interest','Penalty','Discount','Fee','Partial'))" + } + } + }, + "fin_registered_taxes": { + "name": "fin_registered_taxes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_registered_taxes_document_id_idx": { + "name": "fin_registered_taxes_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_registered_taxes_document_id_fk": { + "name": "fin_registered_taxes_document_id_fk", + "tableFrom": "fin_registered_taxes", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_registered_taxes_id": { + "name": "fin_registered_taxes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_registered_taxes_type_chk": { + "name": "fin_registered_taxes_type_chk", + "value": "`fin_registered_taxes`.`type` IN ('ICMS','IPI','PIS','COFINS','CBS','IBS_Municipal','IBS_Estadual')" + }, + "fin_registered_taxes_base_chk": { + "name": "fin_registered_taxes_base_chk", + "value": "`fin_registered_taxes`.`base` >= 0" + }, + "fin_registered_taxes_rate_bps_chk": { + "name": "fin_registered_taxes_rate_bps_chk", + "value": "`fin_registered_taxes`.`rate_bps` >= 0" + }, + "fin_registered_taxes_value_chk": { + "name": "fin_registered_taxes_value_chk", + "value": "`fin_registered_taxes`.`value` >= 0" + } + } + }, + "fin_rejected_suggestions": { + "name": "fin_rejected_suggestions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transaction_id": { + "name": "transaction_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_rejected_suggestions_tx_payable_uq": { + "name": "fin_rejected_suggestions_tx_payable_uq", + "columns": ["transaction_id", "payable_id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_rejected_suggestions_id": { + "name": "fin_rejected_suggestions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_documents": { + "name": "fin_remittance_documents", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_documents_document_idx": { + "name": "fin_remittance_documents_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_documents_your_number_uk": { + "name": "fin_remittance_documents_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittance_documents_remittance_id_document_id_pk": { + "name": "fin_remittance_documents_remittance_id_document_id_pk", + "columns": ["remittance_id", "document_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittance_payables": { + "name": "fin_remittance_payables", + "columns": { + "remittance_id": { + "name": "remittance_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payable_id": { + "name": "payable_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "your_number": { + "name": "your_number", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_remittance_payables_payable_idx": { + "name": "fin_remittance_payables_payable_idx", + "columns": ["payable_id"], + "isUnique": false + }, + "fin_remittance_payables_document_idx": { + "name": "fin_remittance_payables_document_idx", + "columns": ["document_id"], + "isUnique": false + }, + "fin_remittance_payables_your_number_uk": { + "name": "fin_remittance_payables_your_number_uk", + "columns": ["your_number"], + "isUnique": true + } + }, + "foreignKeys": { + "fin_remittance_payables_remittance_id_fk": { + "name": "fin_remittance_payables_remittance_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_remittances", + "columnsFrom": ["remittance_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_payable_id_fk": { + "name": "fin_remittance_payables_payable_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_payables", + "columnsFrom": ["payable_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "fin_remittance_payables_document_id_fk": { + "name": "fin_remittance_payables_document_id_fk", + "tableFrom": "fin_remittance_payables", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_remittance_payables_remittance_id_payable_id_pk": { + "name": "fin_remittance_payables_remittance_id_payable_id_pk", + "columns": ["remittance_id", "payable_id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_remittances": { + "name": "fin_remittances", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cedente_account_id": { + "name": "cedente_account_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nsa": { + "name": "nsa", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generated_at": { + "name": "generated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settled_at": { + "name": "settled_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_remittances_account_nsa_uq": { + "name": "fin_remittances_account_nsa_uq", + "columns": ["cedente_account_id", "nsa"], + "isUnique": true + }, + "fin_remittances_file_name_uq": { + "name": "fin_remittances_file_name_uq", + "columns": ["file_name"], + "isUnique": true + }, + "fin_remittances_status_idx": { + "name": "fin_remittances_status_idx", + "columns": ["status"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_remittances_id": { + "name": "fin_remittances_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_remittances_status_chk": { + "name": "fin_remittances_status_chk", + "value": "`fin_remittances`.`status` IN ('Queued','Transmitted','Failed','Discarded')" + }, + "fin_remittances_nsa_range_chk": { + "name": "fin_remittances_nsa_range_chk", + "value": "`fin_remittances`.`nsa` >= 1 AND `fin_remittances`.`nsa` <= 999999" + } + } + }, + "fin_retentions": { + "name": "fin_retentions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate_bps": { + "name": "rate_bps", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_retentions_document_id_idx": { + "name": "fin_retentions_document_id_idx", + "columns": ["document_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_retentions_document_id_fk": { + "name": "fin_retentions_document_id_fk", + "tableFrom": "fin_retentions", + "tableTo": "fin_documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_retentions_id": { + "name": "fin_retentions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_retentions_type_chk": { + "name": "fin_retentions_type_chk", + "value": "`fin_retentions`.`type` IN ('ISS','IRRF','INSS','CSRF')" + }, + "fin_retentions_base_chk": { + "name": "fin_retentions_base_chk", + "value": "`fin_retentions`.`base` >= 0" + }, + "fin_retentions_rate_bps_chk": { + "name": "fin_retentions_rate_bps_chk", + "value": "`fin_retentions`.`rate_bps` >= 0" + }, + "fin_retentions_value_chk": { + "name": "fin_retentions_value_chk", + "value": "`fin_retentions`.`value` >= 0" + } + } + }, + "fin_statement_transactions": { + "name": "fin_statement_transactions", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statement_id": { + "name": "statement_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "debit_account_ref": { + "name": "debit_account_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fitid": { + "name": "fitid", + "type": "varchar(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "movement": { + "name": "movement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entry_type": { + "name": "entry_type", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payee_name": { + "name": "payee_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memo": { + "name": "memo", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_cents": { + "name": "value_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "balance_after_cents": { + "name": "balance_after_cents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reconciliation_status": { + "name": "reconciliation_status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "fin_statement_transactions_account_fitid_uq": { + "name": "fin_statement_transactions_account_fitid_uq", + "columns": ["debit_account_ref", "fitid"], + "isUnique": true + }, + "fin_statement_transactions_statement_id_idx": { + "name": "fin_statement_transactions_statement_id_idx", + "columns": ["statement_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_statement_transactions_statement_id_fk": { + "name": "fin_statement_transactions_statement_id_fk", + "tableFrom": "fin_statement_transactions", + "tableTo": "fin_bank_statements", + "columnsFrom": ["statement_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_statement_transactions_id": { + "name": "fin_statement_transactions_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_statement_transactions_movement_chk": { + "name": "fin_statement_transactions_movement_chk", + "value": "`fin_statement_transactions`.`movement` IN ('Debit','Credit')" + }, + "fin_statement_transactions_entry_type_chk": { + "name": "fin_statement_transactions_entry_type_chk", + "value": "`fin_statement_transactions`.`entry_type` IN ('PIX','TED','DOC','Fee','Boleto','DARF','Investment','Redemption','Transfer','Other')" + }, + "fin_statement_transactions_recon_status_chk": { + "name": "fin_statement_transactions_recon_status_chk", + "value": "`fin_statement_transactions`.`reconciliation_status` IN ('Pending','Reconciled','ManualEntry')" + } + } + }, + "fin_supplier_view": { + "name": "fin_supplier_view", + "columns": { + "supplier_ref": { + "name": "supplier_ref", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_supplier_view_supplier_ref": { + "name": "fin_supplier_view_supplier_ref", + "columns": ["supplier_ref"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_timeline_field_changes": { + "name": "fin_timeline_field_changes", + "columns": { + "id": { + "name": "id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timeline_entry_id": { + "name": "timeline_entry_id", + "type": "varchar(36) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_value": { + "name": "before_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after_value": { + "name": "after_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_fin_tlfc_entry": { + "name": "idx_fin_tlfc_entry", + "columns": ["timeline_entry_id"], + "isUnique": false + } + }, + "foreignKeys": { + "fin_timeline_field_changes_entry_id_fk": { + "name": "fin_timeline_field_changes_entry_id_fk", + "tableFrom": "fin_timeline_field_changes", + "tableTo": "fin_document_timeline", + "columnsFrom": ["timeline_entry_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "fin_timeline_field_changes_id": { + "name": "fin_timeline_field_changes_id", + "columns": ["id"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "fin_van_return_quarantine": { + "name": "fin_van_return_quarantine", + "columns": { + "object_key": { + "name": "object_key", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_sha256": { + "name": "observed_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_sha256": { + "name": "expected_sha256", + "type": "char(64) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "datetime(3)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "fin_van_return_quarantine_released_idx": { + "name": "fin_van_return_quarantine_released_idx", + "columns": ["released_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "fin_van_return_quarantine_object_key": { + "name": "fin_van_return_quarantine_object_key", + "columns": ["object_key"] + } + }, + "uniqueConstraints": {}, + "checkConstraint": { + "fin_van_return_quarantine_reason_chk": { + "name": "fin_van_return_quarantine_reason_chk", + "value": "`fin_van_return_quarantine`.`reason` IN ('missing-provenance','hash-mismatch','origin-not-logged')" + } + } + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} diff --git a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json index a733f757..ce41afb4 100644 --- a/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json +++ b/src/modules/financial/adapters/persistence/migrations/mysql/meta/_journal.json @@ -407,6 +407,13 @@ "when": 1788718100073, "tag": "0057_lying_vindicator", "breakpoints": true + }, + { + "idx": 58, + "version": "5", + "when": 1788723766782, + "tag": "0058_melodic_nick_fury", + "breakpoints": true } ] } diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index e63a54b8..811e1430 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -3,7 +3,7 @@ // Boundary: todo try/catch converte para Result; nenhum Error cruza a borda // (.claude/rules/adapters.md §"converter para Result na borda"). -import { and, eq } from 'drizzle-orm'; +import { and, eq, ne } from 'drizzle-orm'; import process from 'node:process'; import { type Result, ok, err } from '#src/shared/primitives/result.ts'; @@ -69,6 +69,14 @@ export const createDrizzleCedenteAccountStore = ( eq(finCedenteAccounts.agency, key.agency), eq(finCedenteAccounts.accountNumber, key.accountNumber), eq(finCedenteAccounts.accountDigit, key.accountDigit), + // #995 B4 — a conta EXCLUÍDA não ocupa mais a chave. Sem este predicado, o + // `createCedenteAccount` continuaria recusando o recadastro com + // `cedente-account-duplicate`, e a exclusão não teria efeito nenhum sobre o cadastro. + // + // ⚠️ Duas guardas para o mesmo fato, e não é redundância: aqui é a régua de NEGÓCIO, + // que decide o que o operador vê; a UNIQUE com `natural_key_slot` é a garantia no + // BANCO, que impede duas linhas vivas mesmo se este `where` for reescrito errado. + ne(finCedenteAccounts.status, 'Deleted'), ), ) .limit(1); @@ -87,9 +95,15 @@ export const createDrizzleCedenteAccountStore = ( } }, + // ⚠️ A EXCLUÍDA NÃO APARECE (#995, B4) — nem no grid, nem no filtro "Encerradas", que é servido + // por esta mesma listagem. Ela continua alcançável por `findById`, e é assim que o histórico é + // lido (B5): sair da lista não é sair do sistema. list: async (): Promise> => { try { - const rows = await db.select().from(finCedenteAccounts); + const rows = await db + .select() + .from(finCedenteAccounts) + .where(ne(finCedenteAccounts.status, 'Deleted')); const accounts: CedenteAccount[] = []; for (const row of rows) { const mapped = toDomain(row); diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts index c688e068..f41dff8a 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts @@ -35,11 +35,14 @@ export const createInMemoryCedenteAccountStore = ( ): Promise> => Promise.resolve(ok(accounts.get(id) ?? null)), + // #995 B4 — a conta EXCLUÍDA não ocupa mais a chave natural. Espelha o predicado do adapter + // Drizzle; sem ele, o fake aprovaria um recadastro que o MySQL recusaria (ou o contrário). findByNaturalKey: async ( key: CedenteAccountNaturalKey, ): Promise> => { for (const account of accounts.values()) { if ( + account.status !== 'Deleted' && account.bankCode === key.bankCode && account.agency === key.agency && account.accountNumber === key.accountNumber && @@ -51,8 +54,10 @@ export const createInMemoryCedenteAccountStore = ( return Promise.resolve(ok(null)); }, + // A excluída sai da listagem (grid e filtro "Encerradas") e continua resolvendo por `findById` — + // sair da lista não é sair do sistema (#995, B4/B5). list: async (): Promise> => - Promise.resolve(ok([...accounts.values()])), + Promise.resolve(ok([...accounts.values()].filter((a) => a.status !== 'Deleted'))), // `nextNsa` FICA FORA do path de update, espelhando a regra do adapter Drizzle (ver // `cedente-account-store.drizzle.ts` §`save`): o único caminho de escrita do contador é diff --git a/src/modules/financial/adapters/persistence/schemas/mysql.ts b/src/modules/financial/adapters/persistence/schemas/mysql.ts index 509ad003..0f780d45 100644 --- a/src/modules/financial/adapters/persistence/schemas/mysql.ts +++ b/src/modules/financial/adapters/persistence/schemas/mysql.ts @@ -666,6 +666,15 @@ export const finCedenteAccounts = mysqlTable( convenio: varchar('convenio', { length: 30 }).notNull(), document: varchar('document', { length: 20 }).notNull(), status: varchar('status', { length: 8 }).notNull(), + // #995 B4 — o discriminador do soft delete na UNIQUE de chave natural. `'LIVE'` enquanto a conta + // existe (ativa ou encerrada); o próprio `id` quando `Deleted`. Ver a UNIQUE, abaixo, para o + // mecanismo e para por que NULL não serve. Derivado em `cedente-account.mapper.ts`, num só lugar. + // + // ⚠️ `uuidKey` e não `varchar(36)` cru: a coluna guarda o `id` da própria linha quando `Deleted`, + // e comparação de identificador é BYTE A BYTE — `utf8mb4_unicode_ci` acharia `A` igual a `a` e + // dois ids distintos poderiam colidir na UNIQUE, prendendo a chave natural de novo. É a régua + // que `tests/cleanup/identifier-collation-from-type.test.ts` cobra para toda coluna de 36. + naturalKeySlot: uuidKey('natural_key_slot').notNull().default('LIVE'), nextNsa: int('next_nsa').notNull(), // Extensão conciliação (019) — nullable (ALTER ADD COLUMN não-quebrante, migration 0009). type: varchar('type', { length: 16 }), @@ -677,18 +686,42 @@ export const finCedenteAccounts = mysqlTable( openingBalanceDate: date('opening_balance_date', { mode: 'string' }), }, (t) => [ - check('fin_cedente_accounts_status_chk', sql`${t.status} IN ('Active','Closed')`), + check('fin_cedente_accounts_status_chk', sql`${t.status} IN ('Active','Closed','Deleted')`), check('fin_cedente_accounts_next_nsa_chk', sql`${t.nextNsa} >= 1`), check( 'fin_cedente_accounts_type_chk', sql`${t.type} IS NULL OR ${t.type} IN ('corrente','poupanca','investimento','cartao','outro')`, ), - // FR-016: unicidade por chave natural (banco + agência + conta + dígito). + check( + 'fin_cedente_accounts_status_deleted_chk', + sql`${t.status} <> 'Deleted' OR ${t.naturalKeySlot} = ${t.id}`, + ), + // FR-016: unicidade por chave natural (banco + agência + conta + dígito) — agora com o + // discriminador do soft delete (#995, B4). + // + // ⚠️ POR QUE UMA QUINTA COLUNA, e por que ela não é gambiarra: o B4 exige que a conta EXCLUÍDA + // libere a chave (recadastrar com os mesmos dados passa a ser aceito), enquanto a ENCERRADA + // continua ocupando-a. Um índice sobre as quatro colunas não distingue os dois casos — a linha + // soft-deleted seguiria bloqueando. + // + // A saída que preserva a garantia NO BANCO é discriminar por valor: + // · linha viva (`Active`/`Closed`) → `natural_key_slot = 'LIVE'`, constante ⇒ duas contas com a + // mesma chave colidem, que é o invariante do FR-016; + // · linha `Deleted` → `natural_key_slot = id`, único por linha ⇒ nunca colide com ninguém. + // + // ⚠️ NÃO usar NULL no lugar de `'LIVE'`. Em MySQL, linhas com NULL numa coluna do índice único + // NÃO são consideradas duplicatas — o efeito seria o INVERSO do desejado: as vivas deixariam de + // colidir entre si e a unicidade sumiria em silêncio. + // + // A derivação vive num lugar SÓ, `cedente-account.mapper.ts`, e o CHECK acima é a rede: uma linha + // `Deleted` cujo slot não seja o próprio id é recusada pelo banco, em vez de ocupar a chave para + // sempre por um caminho de escrita que esqueceu de derivar. uniqueIndex('fin_cedente_accounts_natural_key_uq').on( t.bankCode, t.agency, t.accountNumber, t.accountDigit, + t.naturalKeySlot, ), ], ); diff --git a/src/modules/financial/application/use-cases/delete-cedente-account.ts b/src/modules/financial/application/use-cases/delete-cedente-account.ts new file mode 100644 index 00000000..8b4571f7 --- /dev/null +++ b/src/modules/financial/application/use-cases/delete-cedente-account.ts @@ -0,0 +1,58 @@ +import { type Result, ok, err } from '../../../../shared/primitives/result.ts'; +import * as CedenteAccountId from '../../domain/cedente/cedente-account-id.ts'; +import { softDelete } from '../../domain/cedente/cedente-account.ts'; +import type { CedenteAccount } from '../../domain/cedente/types.ts'; +import type { CedenteAccountIdError } from '../../domain/cedente/cedente-account-id.ts'; +import type { + CedenteAccountStore, + CedenteAccountStoreError, +} from '../ports/cedente-account-store.ts'; + +// Exclui a conta-cedente (#995, B3) — SOFT DELETE, obrigatoriamente. +// +// ⚠️ "MANTÉM O HISTÓRICO" NÃO É PREFERÊNCIA, É ESTRUTURA. Remessa, conciliação, extrato e transação +// apontam para esta conta, e as FKs deste módulo são `RESTRICT` de propósito (`.claude/rules/ +// adapters.md`): apagar de verdade destruiria o rastro do que foi enviado ao banco, que é justamente +// o que essas tabelas existem para guardar. Um `DELETE` físico nem passaria — estouraria +// `ER_ROW_IS_REFERENCED_2` na primeira conta que já pagou algo. +// +// O que a exclusão faz, então, é mudar o que a conta SIGNIFICA: +// · sai do grid (inclusive do filtro "Encerradas"); +// · libera a chave natural — recadastrar com os mesmos dados passa a ser aceito (B4); +// · continua alcançável por id, para o histórico ser lido (B5). +// +// Só a partir de `Closed`, e isso é desenho: excluir é irreversível, então exige o passo deliberado +// de encerrar antes. É a mesma disciplina de dupla confirmação que o encerramento já tinha. + +export type DeleteCedenteAccountInput = Readonly<{ id: string }>; + +export type DeleteCedenteAccountError = + | 'cedente-account-not-found' + | 'cedente-account-not-closed-for-delete' + | 'cedente-account-already-deleted' + | CedenteAccountIdError + | CedenteAccountStoreError; + +type Deps = Readonly<{ cedenteStore: CedenteAccountStore }>; + +export const deleteCedenteAccount = + (deps: Deps) => + async ( + input: DeleteCedenteAccountInput, + ): Promise> => { + const id = CedenteAccountId.rehydrate(input.id); + if (!id.ok) return id; + + const found = await deps.cedenteStore.findById(id.value); + if (!found.ok) return found; + if (found.value === null) return err('cedente-account-not-found'); + + const deleted = softDelete(found.value); + if (!deleted.ok) return deleted; + + // O `save` grava o status novo E o discriminador da chave natural, derivado no mapper — é ele + // que efetivamente libera a chave. Ver `schemas/mysql.ts` §`natural_key_slot`. + const saved = await deps.cedenteStore.save(deleted.value); + if (!saved.ok) return saved; + return ok(deleted.value); + }; diff --git a/src/modules/financial/application/use-cases/reopen-cedente-account.ts b/src/modules/financial/application/use-cases/reopen-cedente-account.ts new file mode 100644 index 00000000..d11c11d0 --- /dev/null +++ b/src/modules/financial/application/use-cases/reopen-cedente-account.ts @@ -0,0 +1,51 @@ +import { type Result, ok, err } from '../../../../shared/primitives/result.ts'; +import * as CedenteAccountId from '../../domain/cedente/cedente-account-id.ts'; +import { reopen } from '../../domain/cedente/cedente-account.ts'; +import type { CedenteAccount } from '../../domain/cedente/types.ts'; +import type { CedenteAccountIdError } from '../../domain/cedente/cedente-account-id.ts'; +import type { + CedenteAccountStore, + CedenteAccountStoreError, +} from '../ports/cedente-account-store.ts'; + +// Desfaz o encerramento (#995, B1). Irmão de `close-cedente-account.ts`, e deliberadamente do mesmo +// tamanho: a assimetria entre encerrar e reabrir não está no código, está na SEMÂNTICA — encerrar é +// reversível, excluir não é. +// +// ⚠️ Não existir esta rota foi o que transformou um encerramento por engano num beco em produção +// (06/09): a conta ficou inacessível pelos dois caminhos — não reabria, e o recadastro batia em +// `cedente-account-duplicate` porque a linha encerrada continua ocupando a chave natural. + +export type ReopenCedenteAccountInput = Readonly<{ id: string }>; + +export type ReopenCedenteAccountError = + | 'cedente-account-not-found' + | 'cedente-account-not-closed' + | CedenteAccountIdError + | CedenteAccountStoreError; + +type Deps = Readonly<{ cedenteStore: CedenteAccountStore }>; + +export const reopenCedenteAccount = + (deps: Deps) => + async ( + input: ReopenCedenteAccountInput, + ): Promise> => { + const id = CedenteAccountId.rehydrate(input.id); + if (!id.ok) return id; + + const found = await deps.cedenteStore.findById(id.value); + if (!found.ok) return found; + if (found.value === null) return err('cedente-account-not-found'); + + // A transição — e o que ela preserva — é decisão do DOMÍNIO, não daqui: `reopen` devolve + // `{...account, status: 'Active'}`, então id, convênio, histórico e `nextNsa` vêm junto por + // construção. Montar o objeto neste use case abriria a porta para "reabrir zerando o contador", + // que é a #943 de volta. + const reopened = reopen(found.value); + if (!reopened.ok) return reopened; + + const saved = await deps.cedenteStore.save(reopened.value); + if (!saved.ok) return saved; + return ok(reopened.value); + }; diff --git a/src/modules/financial/domain/cedente/cedente-account.ts b/src/modules/financial/domain/cedente/cedente-account.ts index 40e6e215..6107fad6 100644 --- a/src/modules/financial/domain/cedente/cedente-account.ts +++ b/src/modules/financial/domain/cedente/cedente-account.ts @@ -66,6 +66,9 @@ export const isActive = (account: CedenteAccount): boolean => account.status === export const isClosed = (account: CedenteAccount): boolean => account.status === 'Closed'; +// #995 B3 — soft delete. A linha some do grid, não do banco. +export const isDeleted = (account: CedenteAccount): boolean => account.status === 'Deleted'; + export const close = ( account: CedenteAccount, ): Result => @@ -73,6 +76,44 @@ export const close = ( ? ok(immutable({ ...account, status: 'Closed' })) : err('cedente-account-already-closed'); +/** + * `Closed` → `Active` (#995, B1). + * + * ⚠️ PRESERVA TUDO, e o que mais importa é o que ela NÃO toca: `nextNsa`. Reabrir reiniciando o + * contador seria a #943 entrando pela porta dos fundos — NSA reemitido é retransmissão aos olhos do + * banco. Como a reabertura é um `{...account, status}`, o contador vem junto por construção; e desde + * a #943 ele nem mora mais aqui, mora em `fin_convenio_nsa`. As duas coisas se reforçam. + * + * Só a partir de `Closed`. Conta ativa não tem o que reabrir; conta EXCLUÍDA não volta — é a + * assimetria que dá sentido a existirem duas ações (a P.O., 06/09): encerrar é reversível de + * propósito, excluir não é. + */ +export const reopen = ( + account: CedenteAccount, +): Result => + account.status === 'Closed' + ? ok(immutable({ ...account, status: 'Active' })) + : err('cedente-account-not-closed'); + +/** + * `Closed` → `Deleted` (#995, B3). SOFT delete: a linha permanece. + * + * Só a partir de `Closed`, e o erro tem nome PRÓPRIO em vez de reusar o `not-closed` da reabertura: + * a ação do operador é diferente — ali ele queria reabrir e a conta já estava ativa; aqui ele quer + * excluir e precisa encerrar antes. Uma mensagem só serviria mal às duas. + */ +export const softDelete = ( + account: CedenteAccount, +): Result< + CedenteAccount, + 'cedente-account-not-closed-for-delete' | 'cedente-account-already-deleted' +> => { + if (account.status === 'Deleted') return err('cedente-account-already-deleted'); + if (account.status !== 'Closed') return err('cedente-account-not-closed-for-delete'); + + return ok(immutable({ ...account, status: 'Deleted' })); +}; + export type AllocateNsaError = 'cedente-account-not-active' | 'nsa-exhausted'; export type NsaAllocation = Readonly<{ nsa: Nsa.Nsa; account: CedenteAccount }>; diff --git a/src/modules/financial/domain/cedente/types.ts b/src/modules/financial/domain/cedente/types.ts index da6fe82d..119036c8 100644 --- a/src/modules/financial/domain/cedente/types.ts +++ b/src/modules/financial/domain/cedente/types.ts @@ -2,7 +2,17 @@ import type { CedenteAccountId } from './cedente-account-id.ts'; // Conta-cedente (conta-débito Bradesco da organização) — referência de identidade que liga // documento → conta de pagamento (D-CEDENTE). Valores em EN (C1). -export type CedenteAccountStatus = 'Active' | 'Closed'; +// ⚠️ TRÊS estados, e `Deleted` NÃO é "sumiu" — é SOFT DELETE (#995, B3). A linha permanece, porque +// remessa, conciliação e extrato apontam para ela: apagar de verdade destruiria o rastro do que foi +// enviado ao banco, que é o oposto do propósito desses vínculos (as FKs são `RESTRICT` justamente +// por isso — `.claude/rules/adapters.md`). +// +// O que muda em `Deleted`, e é o contrato que a P.O. definiu em 06/09: +// · SAI do grid de contas, inclusive do filtro "Encerradas"; +// · CONTINUA alcançável por id, para o histórico ser lido; +// · LIBERA a chave natural — cadastrar de novo com os mesmos dados passa a ser aceito; +// · NÃO é reversível (ao contrário de `Closed`, que volta por `reopen`). +export type CedenteAccountStatus = 'Active' | 'Closed' | 'Deleted'; // Tipo de conta bancária (extensão conciliação 019). Opcional no agregado para não quebrar // contas criadas pela 016/CNAB, que não o registravam (FR-013). @@ -76,4 +86,11 @@ export type CedenteAccountError = | 'invalid-nsa' | 'invalid-account-type' | 'opening-balance-requires-date' - | 'cedente-account-already-closed'; + | 'cedente-account-already-closed' + // #995 B2 — reabrir só faz sentido a partir de `Closed`. Nome próprio porque a ação do operador é + // outra: se já está ativa, não há o que reabrir; se está excluída, não há volta. + | 'cedente-account-not-closed' + // #995 B3 — excluir exige encerrar antes. É a mesma disciplina do encerramento: a saída é um + // caminho de duas etapas, e a mensagem diz qual falta. + | 'cedente-account-not-closed-for-delete' + | 'cedente-account-already-deleted'; diff --git a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts index 82353aff..7d6ea19d 100644 --- a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts +++ b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts @@ -221,6 +221,129 @@ describe('financial/http — cedente-accounts (019) — W0 RED', () => { ); }); + /* + * REABRIR E EXCLUIR (#995, B1/B3/B4/B5) — o ciclo completo pela borda. + * + * ⚠️ ESTE BLOCO EXISTE PARA FECHAR UM BECO MEDIDO EM PRODUÇÃO (06/09). Uma conta encerrada por + * engano ficava 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 — o tipo de intervenção que a #879 já mostrou custar caro. + */ + const createAccount = async (over: Record) => { + const res = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body(over), + }); + assert.equal(res.statusCode, 201, res.body); + return (res.json() as { id: string }).id; + }; + + const transition = async (id: string, path: 'close' | 'reopen') => + handle.app.inject({ + method: 'POST', + url: `/api/v2/financial/cedente-accounts/${id}/${path}`, + headers: { authorization: `Bearer ${WRITER}` }, + }); + + const remove = async (id: string) => + handle.app.inject({ + method: 'DELETE', + url: `/api/v2/financial/cedente-accounts/${id}`, + headers: { authorization: `Bearer ${WRITER}` }, + }); + + it('#995 B1: encerrar e REABRIR devolve a conta ao ativo', async () => { + const id = await createAccount({ accountNumber: '770011', nickname: 'Conta a reabrir' }); + assert.equal((await transition(id, 'close')).statusCode, 200); + + const reopened = await transition(id, 'reopen'); + assert.equal(reopened.statusCode, 200, reopened.body); + assert.equal((reopened.json() as { status: string }).status, 'Active'); + }); + + it('#995 B2: reabrir conta ATIVA → 409', async () => { + const id = await createAccount({ accountNumber: '770022', nickname: 'Conta ativa' }); + + const res = await transition(id, 'reopen'); + assert.equal(res.statusCode, 409, res.body); + }); + + it('#995 B3: excluir conta ATIVA → 409 (encerre antes)', async () => { + const id = await createAccount({ accountNumber: '770033', nickname: 'Conta ativa' }); + + const res = await remove(id); + assert.equal(res.statusCode, 409, res.body); + }); + + /* + * ⚠️ O CASO CENTRAL — B4 + B5 juntos, porque separá-los deixaria passar a meia-correção. + * + * Excluir tem de fazer as DUAS coisas: tirar a conta do grid E liberar a chave natural. Um teste + * só da listagem passaria com a chave ainda presa, e o operador continuaria no beco — que é + * exatamente o estado que motivou a issue. + * + * E o histórico segue alcançável por id: sair da lista não é sair do sistema. + */ + it('#995 B4/B5: a conta excluída sai da lista, LIBERA a chave, e segue legível por id', async () => { + const naturalKey = { accountNumber: '770044', nickname: 'Conta a excluir' }; + const id = await createAccount(naturalKey); + assert.equal((await transition(id, 'close')).statusCode, 200); + + const deleted = await remove(id); + assert.equal(deleted.statusCode, 200, deleted.body); + assert.equal((deleted.json() as { status: string }).status, 'Deleted'); + + // B4 — sumiu do grid (a listagem serve o filtro "Encerradas" também). + const list = await handle.app.inject({ + method: 'GET', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${READER}` }, + }); + assert.equal(list.statusCode, 200); + const ids = (list.json() as readonly { id: string }[]).map((a) => a.id); + assert.ok(!ids.includes(id), 'a conta excluída continua aparecendo na listagem'); + + // B4 — a chave natural foi liberada: o MESMO cadastro passa a ser aceito. + const recreated = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body(naturalKey), + }); + assert.equal( + recreated.statusCode, + 201, + `a chave natural continua presa pela conta excluída: ${recreated.body}`, + ); + + // B5 — o histórico segue alcançável pelo id antigo. + const read = await handle.app.inject({ + method: 'GET', + url: `/api/v2/financial/cedente-accounts/${id}`, + headers: { authorization: `Bearer ${READER}` }, + }); + assert.equal(read.statusCode, 200, 'a conta excluída deixou de ser legível por id'); + assert.equal((read.json() as { status: string }).status, 'Deleted'); + }); + + // ⚠️ O outro lado do B4/B7: a conta ENCERRADA continua ocupando a chave. É o invariante que dá + // sentido às duas ações — se encerrar já liberasse, não haveria por que existir o excluir. + it('#995 B7: conta ENCERRADA continua ocupando a chave — o caminho é reabrir ou excluir', async () => { + const naturalKey = { accountNumber: '770055', nickname: 'Conta encerrada' }; + const id = await createAccount(naturalKey); + assert.equal((await transition(id, 'close')).statusCode, 200); + + const recreated = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body(naturalKey), + }); + assert.equal(recreated.statusCode, 409, recreated.body); + }); + it('CA-US2: POST /cedente-accounts/:id/close → rota existe (≠ 404)', async () => { const id = '11111111-1111-4111-8111-111111111111'; const res = await handle.app.inject({ diff --git a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts index af539528..1257c3e9 100644 --- a/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts +++ b/tests/modules/financial/adapters/persistence/mappers/cedente-account.mapper.test.ts @@ -37,6 +37,9 @@ const rowOf = ( convenio: account.convenio, document: account.document, status: account.status as string, + // #995 B4 — o discriminador da UNIQUE de chave natural. `'LIVE'` no fixture porque o padrão é a + // conta viva; os casos de exclusão passam o próprio id, que é o que o mapper deriva. + naturalKeySlot: account.status === 'Deleted' ? (account.id as string) : 'LIVE', nextNsa: account.nextNsa, // #856 — DV da agência (posição 058); nullable, e a conta migrada nasce sem ele. agencyDigit: account.agencyDigit ?? null, diff --git a/tests/modules/financial/domain/cedente/cedente-account-lifecycle.test.ts b/tests/modules/financial/domain/cedente/cedente-account-lifecycle.test.ts new file mode 100644 index 00000000..b8dae9ae --- /dev/null +++ b/tests/modules/financial/domain/cedente/cedente-account-lifecycle.test.ts @@ -0,0 +1,154 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { isErr, isOk } from '#src/shared/index.ts'; +import * as CedenteAccountId from '#src/modules/financial/domain/cedente/cedente-account-id.ts'; +import { + create, + close, + reopen, + softDelete, + isActive, + isClosed, + isDeleted, +} from '#src/modules/financial/domain/cedente/cedente-account.ts'; +import type { CedenteAccount } from '#src/modules/financial/domain/cedente/types.ts'; + +/** + * A MÁQUINA DE ESTADOS da conta-cedente (#995, B1/B3). + * + * `Active` ⇄ `Closed` → `Deleted`. A assimetria é o desenho, não descuido (P.O., 06/09): + * + * · **encerrar é reversível** — o operador erra, ou quer tirar do fluxo e manter a linha no grid; + * · **excluir não é** — e por isso exige o passo deliberado de encerrar antes. + * + * Não existir a volta foi o que transformou um encerramento por engano num beco em produção: a conta + * não reabria, e o recadastro batia em `cedente-account-duplicate` porque a linha encerrada continua + * ocupando a chave natural. + */ +const account = (): CedenteAccount => { + const r = create({ + id: CedenteAccountId.generate(), + bankCode: '237', + agency: '1234', + accountNumber: '567890', + accountDigit: '1', + convenio: '123456', + document: '12345678000190', + nextNsa: 42, + }); + if (!r.ok) throw new Error('setup: cedente'); + return r.value; +}; + +const closedAccount = (): CedenteAccount => { + const r = close(account()); + if (!r.ok) throw new Error('setup: close'); + return r.value; +}; + +describe('cedente-account — reabrir (#995 B1)', () => { + it('Closed → Active', () => { + const r = reopen(closedAccount()); + + assert.ok(isOk(r)); + assert.equal(r.value.status, 'Active'); + assert.ok(isActive(r.value)); + }); + + /* + * ⚠️ O CASO QUE NÃO PODE FALHAR — CA5 da issue. + * + * Reabrir reiniciando o contador seria a #943 entrando pela porta dos fundos: NSA reemitido é + * RETRANSMISSÃO aos olhos do banco. Desde a #943 o contador nem mora mais aqui (está em + * `fin_convenio_nsa`), e a reabertura é um spread — as duas coisas se reforçam, e este caso vigia + * que nenhuma das duas mude sem a outra. + */ + it('B1/CA5: preserva id, convênio e nextNsa — reabrir NÃO reinicia contador', () => { + const before = closedAccount(); + const r = reopen(before); + + assert.ok(isOk(r)); + assert.equal(r.value.id, before.id, 'a identidade mudou — a conta virou outra'); + assert.equal(r.value.convenio, before.convenio); + assert.equal(r.value.nextNsa, 42, 'o contador foi reiniciado pela reabertura'); + }); + + it('B2: reabrir conta ATIVA é recusado com nome próprio', () => { + const r = reopen(account()); + + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-account-not-closed'); + }); + + // Excluída não volta — é o que distingue as duas ações. + it('B2: reabrir conta EXCLUÍDA é recusado — a exclusão não tem volta', () => { + const deleted = softDelete(closedAccount()); + assert.ok(isOk(deleted)); + + const r = reopen(deleted.value); + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-account-not-closed'); + }); + + // Ida e volta sem perda: encerrar e reabrir devolve a conta ao estado inicial. + it('o ciclo Active → Closed → Active preserva o agregado inteiro', () => { + const before = account(); + const closed = close(before); + assert.ok(isOk(closed)); + const back = reopen(closed.value); + assert.ok(isOk(back)); + + assert.deepEqual(back.value, before); + }); +}); + +describe('cedente-account — excluir (#995 B3)', () => { + it('Closed → Deleted', () => { + const r = softDelete(closedAccount()); + + assert.ok(isOk(r)); + assert.equal(r.value.status, 'Deleted'); + assert.ok(isDeleted(r.value)); + assert.ok(!isClosed(r.value)); + }); + + // ⚠️ SOFT delete: o agregado permanece inteiro. Remessa, conciliação e extrato apontam para esta + // conta, e as FKs do módulo são `RESTRICT` — apagar de verdade destruiria o rastro do que foi + // enviado ao banco, que é o que essas tabelas existem para guardar. + it('B3: excluir preserva o agregado — só o status muda', () => { + const before = closedAccount(); + const r = softDelete(before); + + assert.ok(isOk(r)); + assert.deepEqual(r.value, { ...before, status: 'Deleted' }); + }); + + it('B3: excluir conta ATIVA é recusado — encerre antes', () => { + const r = softDelete(account()); + + assert.ok(isErr(r)); + assert.equal(r.error, 'cedente-account-not-closed-for-delete'); + }); + + // Slug próprio, e não o `not-closed-for-delete`: quem já excluiu não precisa "encerrar antes". + it('B3: excluir conta JÁ excluída tem recusa própria', () => { + const deleted = softDelete(closedAccount()); + assert.ok(isOk(deleted)); + + const again = softDelete(deleted.value); + assert.ok(isErr(again)); + assert.equal(again.error, 'cedente-account-already-deleted'); + }); + + // O terceiro estado não confunde os predicados existentes — quem perguntava "está ativa?" continua + // recebendo `false`, e não passa a receber `true` por a conta ter saído do grid. + it('conta excluída não é ativa nem encerrada', () => { + const deleted = softDelete(closedAccount()); + assert.ok(isOk(deleted)); + + assert.equal(isActive(deleted.value), false); + assert.equal(isClosed(deleted.value), false); + assert.equal(isDeleted(deleted.value), true); + }); +}); From 9694ed11337f186fc9e788b5cc8446b03928f596 Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 17:28:52 -0300 Subject: [PATCH 11/13] =?UTF-8?q?feat(financial):=20o=20saldo=20de=20abert?= =?UTF-8?q?ura=20passa=20a=20ser=20edit=C3=A1vel=20=E2=80=94=20o=20caminho?= =?UTF-8?q?=20que=20evitava=20a=20duplicata=20(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 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 --- src/modules/financial/adapters/http/plugin.ts | 7 ++ .../financial/adapters/http/schemas.ts | 6 ++ .../use-cases/edit-cedente-account.ts | 40 ++++++++- .../use-cases/edit-cedente-account.test.ts | 83 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/modules/financial/adapters/http/plugin.ts b/src/modules/financial/adapters/http/plugin.ts index 228597ac..5ad41f99 100644 --- a/src/modules/financial/adapters/http/plugin.ts +++ b/src/modules/financial/adapters/http/plugin.ts @@ -2315,6 +2315,13 @@ const financialRoutes = ...(b.typeLabel !== undefined ? { typeLabel: b.typeLabel } : {}), ...(b.nickname !== undefined ? { nickname: b.nickname } : {}), ...(b.bankName !== undefined ? { bankName: b.bankName } : {}), + // Money em string de centavos na borda (convenção do módulo) → número no domínio. + ...(b.openingBalanceCents !== undefined + ? { openingBalanceCents: Number(b.openingBalanceCents) } + : {}), + ...(b.openingBalanceDate !== undefined + ? { openingBalanceDate: b.openingBalanceDate } + : {}), }); if (!result.ok) return sendDomainError(reply, result.error); return sendResult(reply, ok(cedenteAccountToDto(result.value)), { ok: 200 }); diff --git a/src/modules/financial/adapters/http/schemas.ts b/src/modules/financial/adapters/http/schemas.ts index 796a5b49..3780fdb4 100644 --- a/src/modules/financial/adapters/http/schemas.ts +++ b/src/modules/financial/adapters/http/schemas.ts @@ -902,6 +902,12 @@ export const editCedenteAccountBodySchema = z.object({ typeLabel: z.string().min(1).max(120).optional(), nickname: z.string().min(1).max(120).optional(), bankName: z.string().min(1).max(120).optional(), + // #995 — saldo de abertura editável. A conta migrada veio com saldo congelado do começo do ano, e + // não poder corrigi-lo foi o que levou o operador a criar contas NOVAS para gerar remessa. + // Travado por histórico no use case (FR-008): com extrato importado, mudá-lo reescreveria todo + // saldo calculado em cima dele. + openingBalanceCents: centsStringSchema.optional(), + openingBalanceDate: z.iso.date().optional(), }); export const cedenteAccountIdParamSchema = z.object({ diff --git a/src/modules/financial/application/use-cases/edit-cedente-account.ts b/src/modules/financial/application/use-cases/edit-cedente-account.ts index 57ae2f0b..7ff36813 100644 --- a/src/modules/financial/application/use-cases/edit-cedente-account.ts +++ b/src/modules/financial/application/use-cases/edit-cedente-account.ts @@ -36,12 +36,26 @@ export type EditCedenteAccountInput = Readonly<{ typeLabel?: string; // #206: texto livre (metadado, não dado bancário travável). nickname?: string; bankName?: string; + // #995 — SALDO DE ABERTURA. Só entrava na criação, e a ausência tinha consequência operacional + // real: a conta migrada do legado veio com saldo congelado do começo do ano, e sem poder corrigi-lo + // o operador criou contas NOVAS para gerar remessa — que é a origem das duplicatas que a issue + // trata. Alinhar o saldo e informar o multipag resolveria sem duplicar nada. + // + // ⚠️ Travado por HISTÓRICO, como 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. A decisão da P.O. (06/09) é manter a edição liberada + // nesta fase e endurecer depois — o guard por histórico é o "depois" que já dá para ter agora, sem + // esperar a trilha de auditoria. + openingBalanceCents?: number; + openingBalanceDate?: string; }>; export type EditCedenteAccountError = | 'cedente-account-not-found' | 'cedente-account-bank-data-locked' | 'cedente-convenio-already-set' + // FR-006 — o par saldo+data é coeso, e a edição pode quebrá-lo tanto quanto a criação. + | 'opening-balance-requires-date' | CedenteAccountIdError | CedenteAccountStoreError | CedenteAccountHistoryError; @@ -78,13 +92,20 @@ export const editCedenteAccount = found.value.agencyDigit !== undefined && found.value.agencyDigit !== agencyDigitPatch; + // O saldo de abertura entra na MESMA trava do dado bancário, e pela mesma razão de fundo: os dois + // são premissas de cálculos já feitos. Mudar o saldo de abertura de uma conta que já importou + // extrato reescreve o saldo de todos os dias seguintes, sem nada apontar a causa. + const wantsOpeningBalanceChange = + input.openingBalanceCents !== undefined || input.openingBalanceDate !== undefined; + const wantsBankDataChange = input.bankCode !== undefined || input.agency !== undefined || input.accountNumber !== undefined || input.accountDigit !== undefined || input.type !== undefined || - wantsAgencyDigitSwap; + wantsAgencyDigitSwap || + wantsOpeningBalanceChange; if (wantsBankDataChange) { const hist = await deps.accountHistory.hasActivity(id.value); @@ -146,8 +167,25 @@ export const editCedenteAccount = ...(input.typeLabel !== undefined ? { typeLabel: input.typeLabel } : {}), ...(input.nickname !== undefined ? { nickname: input.nickname } : {}), ...(input.bankName !== undefined ? { bankName: input.bankName } : {}), + ...(input.openingBalanceCents !== undefined + ? { openingBalanceCents: input.openingBalanceCents } + : {}), + ...(input.openingBalanceDate !== undefined + ? { openingBalanceDate: input.openingBalanceDate } + : {}), }; + // ⚠️ O PAR SALDO+DATA É COESO (FR-006), e a edição tem de cobrar isso no RESULTADO, não no + // patch. Uma conta sem saldo que recebe só os centavos ficaria com valor e sem data — estado que + // o construtor recusa na criação e que entraria pela edição sem esta guarda. É o mesmo invariante, + // e a régua é a do domínio: perguntar aqui pelo `create` seria uma segunda definição do par. + if ( + (updated.openingBalanceCents === undefined) !== + (updated.openingBalanceDate === undefined) + ) { + return err('opening-balance-requires-date'); + } + const saved = await deps.cedenteStore.save(updated); if (!saved.ok) return saved; return ok(updated); diff --git a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts index d52a4973..a2f87bf0 100644 --- a/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts +++ b/tests/modules/financial/application/use-cases/edit-cedente-account.test.ts @@ -151,6 +151,89 @@ describe('edit-cedente-account — o convênio preenche, mas não troca (#722/#8 }); }); +/** + * O SALDO DE ABERTURA passa a ser editável (#995). + * + * ⚠️ A AUSÊNCIA DISSO É A ORIGEM OPERACIONAL DAS DUPLICATAS. A conta migrada do legado veio com o + * saldo congelado do começo do ano, e o campo só existia na criação — sem poder corrigi-lo, o + * operador criou contas NOVAS para conseguir gerar remessa. Alinhar o saldo e informar o multipag + * resolveria sem duplicar nada. + * + * ⚠️ E ELE ENTRA NA TRAVA FR-008, junto com o dado bancário, pela mesma razão de fundo: 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. A P.O. + * decidiu manter a edição liberada nesta fase e endurecer depois; o guard por histórico é o "depois" + * que já dá para ter agora, sem esperar a trilha de auditoria. + */ +describe('edit-cedente-account — o saldo de abertura (#995)', () => { + const editBalance = async ( + account: unknown, + patch: Readonly<{ openingBalanceCents?: number; openingBalanceDate?: string }>, + hasHistory = false, + ) => + editCedenteAccount(deps(account, hasHistory) as never)({ + id: String((account as { id: unknown }).id), + ...patch, + }); + + it('corrige o saldo congelado do legado — o caminho que evitava a duplicata', async () => { + const r = await editBalance(buildAccount('123456'), { + openingBalanceCents: 250_000, + openingBalanceDate: '2026-09-01', + }); + + assert.equal(r.ok, true); + if (r.ok) { + assert.equal(r.value.openingBalanceCents, 250_000); + assert.equal(r.value.openingBalanceDate, '2026-09-01'); + } + }); + + // ⚠️ A guarda que protege a conciliação: com extrato importado, o saldo de abertura vira premissa + // de cálculos já feitos. + it('conta COM histórico recusa a edição do saldo — ele é a base do que já foi conciliado', async () => { + const r = await editBalance( + buildAccount('123456'), + { openingBalanceCents: 1, openingBalanceDate: '2026-09-01' }, + true, + ); + + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'cedente-account-bank-data-locked'); + }); + + /* + * FR-006 — o par saldo+data é 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 que não tinha saldo deixaria valor sem data — estado que o + * domínio nunca produziria. Por isso a checagem é sobre o RESULTADO, não sobre o patch. + */ + it('FR-006: mandar só os centavos numa conta sem saldo é recusado', async () => { + const r = await editBalance(buildAccount('123456'), { openingBalanceCents: 100 }); + + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error, 'opening-balance-requires-date'); + }); + + // O outro lado do par: numa conta que JÁ tem os dois, mandar só um é legítimo — o resultado + // continua coeso. Sem este caso, alguém "corrigiria" a guarda para olhar o patch e quebraria isto. + it('FR-006: numa conta que já tem o par, corrigir só um dos dois é aceito', async () => { + const withBalance = { + ...buildAccount('123456'), + openingBalanceCents: 100, + openingBalanceDate: '2026-01-01', + }; + const r = await editBalance(withBalance, { openingBalanceCents: 999 }); + + assert.equal(r.ok, true); + if (r.ok) { + assert.equal(r.value.openingBalanceCents, 999); + assert.equal(r.value.openingBalanceDate, '2026-01-01', 'a data se perdeu na edição'); + } + }); +}); + /** * O CONVÊNIO VOLTA A SER EDITÁVEL NA CONTA ENCERRADA (#995, B8.1). * From 3f86864f03fbdc4c53506cee405315f9b132e9af Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 18:02:32 -0300 Subject: [PATCH 12/13] =?UTF-8?q?fix(financial):=20a=20chave=20natural=20d?= =?UTF-8?q?a=20conta-cedente=20ganha=20forma=20can=C3=B4nica=20(#995,=20bl?= =?UTF-8?q?oco=20A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../repos/cedente-account-store.drizzle.ts | 53 +++--- .../repos/cedente-account-store.in-memory.ts | 13 +- .../financial/domain/cedente/natural-key.ts | 94 ++++++++++ .../http/financial-cedente.http.test.ts | 69 ++++++++ .../domain/cedente/natural-key.test.ts | 163 ++++++++++++++++++ 5 files changed, 360 insertions(+), 32 deletions(-) create mode 100644 src/modules/financial/domain/cedente/natural-key.ts create mode 100644 tests/modules/financial/domain/cedente/natural-key.test.ts diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index 811e1430..ca083d82 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -3,7 +3,7 @@ // Boundary: todo try/catch converte para Result; nenhum Error cruza a borda // (.claude/rules/adapters.md §"converter para Result na borda"). -import { and, eq, ne } from 'drizzle-orm'; +import { eq, ne } from 'drizzle-orm'; import process from 'node:process'; import { type Result, ok, err } from '#src/shared/primitives/result.ts'; @@ -17,6 +17,7 @@ import type { } from '#src/modules/financial/application/ports/cedente-account-store.ts'; import { isActive } from '#src/modules/financial/domain/cedente/cedente-account.ts'; import * as NsaSequence from '#src/modules/financial/domain/cedente/nsa-sequence.ts'; +import { canonicalNaturalKey } from '#src/modules/financial/domain/cedente/natural-key.ts'; import * as Nsa from '#src/modules/financial/domain/cedente/nsa.ts'; import type { FinancialMysqlHandle } from '#src/modules/financial/adapters/persistence/drivers/mysql-driver.ts'; import { finCedenteAccounts, finConvenioNsa } from '../schemas/mysql.ts'; @@ -56,6 +57,22 @@ export const createDrizzleCedenteAccountStore = ( } }, + // ⚠️ A COMPARAÇÃO É CANÔNICA, E ACONTECE EM TS — não no `where` (#995, bloco A). + // + // Comparar as colunas cruas com `eq()` era o defeito: `'7'` e `'007'`, `'0012345'` e `'12345'`, + // `'1234-1'` e `'1234'` são a MESMA conta para o banco e strings diferentes para o `=`. Foi assim + // que o segundo cadastro passou em produção (06/09) sobre uma conta que já existia. + // + // Escrever `LPAD`/`TRIM LEADING` no `where` 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 aqui. + // A régua vive em `domain/cedente/natural-key.ts`, e os dois adapters descem até ela. + // + // O custo é varrer as linhas vivas em vez de usar o índice. Aceitável por medida, não por + // descuido: esta tabela guarda as contas bancárias DA ORGANIZAÇÃO — unidades, não milhares. + // + // O filtro de `Deleted` continua no SQL (#995, B4): a conta excluída não ocupa mais a chave, e + // isso é predicado de linha, não de forma canônica. findByNaturalKey: async ( key: CedenteAccountNaturalKey, ): Promise> => { @@ -63,32 +80,18 @@ export const createDrizzleCedenteAccountStore = ( const rows = await db .select() .from(finCedenteAccounts) - .where( - and( - eq(finCedenteAccounts.bankCode, key.bankCode), - eq(finCedenteAccounts.agency, key.agency), - eq(finCedenteAccounts.accountNumber, key.accountNumber), - eq(finCedenteAccounts.accountDigit, key.accountDigit), - // #995 B4 — a conta EXCLUÍDA não ocupa mais a chave. Sem este predicado, o - // `createCedenteAccount` continuaria recusando o recadastro com - // `cedente-account-duplicate`, e a exclusão não teria efeito nenhum sobre o cadastro. - // - // ⚠️ Duas guardas para o mesmo fato, e não é redundância: aqui é a régua de NEGÓCIO, - // que decide o que o operador vê; a UNIQUE com `natural_key_slot` é a garantia no - // BANCO, que impede duas linhas vivas mesmo se este `where` for reescrito errado. - ne(finCedenteAccounts.status, 'Deleted'), - ), - ) - .limit(1); - const row = rows[0]; - if (row === undefined) return ok(null); + .where(ne(finCedenteAccounts.status, 'Deleted')); - const mapped = toDomain(row); - if (!mapped.ok) { - logStore('findByNaturalKey:map', mapped.error); - return err('cedente-account-store-unavailable'); + const wanted = canonicalNaturalKey(key); + for (const row of rows) { + const mapped = toDomain(row); + if (!mapped.ok) { + logStore('findByNaturalKey:map', mapped.error); + return err('cedente-account-store-unavailable'); + } + if (canonicalNaturalKey(mapped.value) === wanted) return ok(mapped.value); } - return ok(mapped.value); + return ok(null); } catch (cause) { logStore('findByNaturalKey', cause); return err('cedente-account-store-unavailable'); diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts index f41dff8a..07775c49 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.in-memory.ts @@ -9,6 +9,7 @@ import type { } from '../../../application/ports/cedente-account-store.ts'; import { isActive } from '../../../domain/cedente/cedente-account.ts'; import * as NsaSequence from '../../../domain/cedente/nsa-sequence.ts'; +import { canonicalNaturalKey } from '../../../domain/cedente/natural-key.ts'; import type { Nsa } from '../../../domain/cedente/nsa.ts'; // Adapter in-memory do CedenteAccountStore (testes / boot sem DB). @@ -37,17 +38,15 @@ export const createInMemoryCedenteAccountStore = ( // #995 B4 — a conta EXCLUÍDA não ocupa mais a chave natural. Espelha o predicado do adapter // Drizzle; sem ele, o fake aprovaria um recadastro que o MySQL recusaria (ou o contrário). + // ⚠️ Comparação CANÔNICA, pela régua do domínio (#995, bloco A) — a MESMA que o adapter Drizzle + // usa. Comparar campo a campo aqui faria o fake aprovar um cadastro que o MySQL recusaria, ou o + // contrário: `'7'` e `'007'` são a mesma conta bancária e strings diferentes. findByNaturalKey: async ( key: CedenteAccountNaturalKey, ): Promise> => { + const wanted = canonicalNaturalKey(key); for (const account of accounts.values()) { - if ( - account.status !== 'Deleted' && - account.bankCode === key.bankCode && - account.agency === key.agency && - account.accountNumber === key.accountNumber && - account.accountDigit === key.accountDigit - ) { + if (account.status !== 'Deleted' && canonicalNaturalKey(account) === wanted) { return Promise.resolve(ok(account)); } } diff --git a/src/modules/financial/domain/cedente/natural-key.ts b/src/modules/financial/domain/cedente/natural-key.ts new file mode 100644 index 00000000..7aed9eae --- /dev/null +++ b/src/modules/financial/domain/cedente/natural-key.ts @@ -0,0 +1,94 @@ +// A CHAVE NATURAL da conta-cedente, na forma CANÔNICA (#995, bloco A). +// +// ## O defeito que isto fecha +// +// A duplicata comparava as quatro colunas como STRING CRUA. A mesma conta bancária, escrita de outro +// jeito, entrava de novo — e foi o que aconteceu 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 +// accountDigit '' accountDigit '3' 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/2026) +// +// · banco — SEMPRE 3 dígitos com zeros à esquerda (`'7'` → `'007'`). Não é escolha nossa: é o +// que o CNAB grava (`num(bankCode, 3)`) e o que a tabela FEBRABAN do front já documenta como +// forma canônica do código de compensação. +// · agência — só os dígitos. O DV tem coluna própria desde a #856, então separador aqui é resíduo +// de cadastro antigo, não informação. +// · conta — sem zeros à esquerda. `'0012345'` e `'12345'` são a mesma conta. +// · dígito — só o caractere, em caixa alta (o `P` do Bradesco existe — ver `account-check-digit`). +// +// ## Por que a comparação é em TS, e não em SQL +// +// Escrever `LPAD`/`TRIM LEADING` no `where` do adapter Drizzle criaria uma SEGUNDA definição da +// regra, e o fake in-memory precisaria de uma terceira. Duas réguas para o mesmo fato divergem na +// primeira correção feita só numa delas — é a classe de defeito que a #863 e a #837 documentam neste +// módulo. Com a régua aqui, os dois adapters descem até ela. +// +// O custo é varrer as linhas em vez de usar índice. É aceitável e não por descuido: esta tabela +// guarda as contas bancárias DA ORGANIZAÇÃO — unidades, não milhares. +// +// ## ⚠️ Isto NÃO grava nada, e a distinção é deliberada +// +// As colunas continuam com o que o operador digitou: a tela segue mostrando `0288` e a máscara +// `XXXX-DV` do front continua funcionando. A forma canônica existe para COMPARAR. +// +// E não há UNIQUE canônica no banco — ainda. Pôr uma agora reprovaria a migration contra as +// duplicatas que já existem, derrubando o deploy. A régua abaixo impede a duplicata NOVA; a garantia +// no banco entra quando o cadastro estiver saneado, e a CA3 pede que as colisões existentes sejam +// REPORTADAS, não resolvidas pelo código. + +import { splitCheckDigit } from '../payout/payee-account.ts'; + +export type CedenteNaturalKeyParts = Readonly<{ + bankCode: string; + agency: string; + accountNumber: string; + accountDigit: string; +}>; + +const BANK_CODE_WIDTH = 3; + +const digitsOnly = (raw: string): string => raw.replace(/\D/g, ''); + +// ⚠️ A AGÊNCIA NÃO PODE PASSAR POR `digitsOnly`, e isto foi medido: `digitsOnly('1234-1')` devolve +// `'12341'` — CONCATENA a agência com o DV, que é precisamente a corrupção que o #856 corrigiu no +// emissor. `1234-1` é a agência `1234`; o `1` é dígito, não parte do número. +// +// A gramática é `splitCheckDigit`, do payout — a MESMA que o ETL usa desde o #856 para decompor a +// agência legada. Reescrevê-la aqui criaria a segunda cópia que aquela issue existiu para eliminar. +// Sem separador reconhecível, sobra o que houver de dígito. +const agencyBase = (raw: string): string => { + const split = splitCheckDigit(raw.trim()); + return split === null ? digitsOnly(raw) : digitsOnly(split.base); +}; + +// Zeros à esquerda somem, mas o valor inteiro de zeros NÃO vira string vazia: `'000'` é um dado +// presente e mal preenchido, e colapsá-lo em `''` o tornaria indistinguível de campo em branco. +const withoutLeadingZeros = (raw: string): string => raw.replace(/^0+(?=.)/, ''); + +/** + * A chave canônica, como string única. + * + * O separador `|` não pode aparecer em nenhuma das partes — todas são dígitos ou um caractere de DV + * —, então não há como duas chaves diferentes colidirem por ambiguidade de junção. + */ +export const canonicalNaturalKey = (parts: CedenteNaturalKeyParts): string => { + const bankCode = digitsOnly(parts.bankCode).padStart(BANK_CODE_WIDTH, '0'); + const agency = withoutLeadingZeros(agencyBase(parts.agency)); + const accountNumber = withoutLeadingZeros(digitsOnly(parts.accountNumber)); + const accountDigit = parts.accountDigit.trim().toUpperCase(); + + return `${bankCode}|${agency}|${accountNumber}|${accountDigit}`; +}; + +/** As duas escritas apontam para a mesma conta bancária? */ +export const isSameCedenteAccount = ( + a: CedenteNaturalKeyParts, + b: CedenteNaturalKeyParts, +): boolean => canonicalNaturalKey(a) === canonicalNaturalKey(b); diff --git a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts index 7d6ea19d..d116dcb8 100644 --- a/tests/modules/financial/adapters/http/financial-cedente.http.test.ts +++ b/tests/modules/financial/adapters/http/financial-cedente.http.test.ts @@ -344,6 +344,75 @@ describe('financial/http — cedente-accounts (019) — W0 RED', () => { assert.equal(recreated.statusCode, 409, recreated.body); }); + /* + * ⚠️ CA2 DO BLOCO A (#995) — O CASO DE PRODUÇÃO, PELO CAMINHO INTEIRO. + * + * Em 06/09 existia a conta migrada do legado, ATIVA. O operador cadastrou outra, com os MESMOS + * dados bancários escritos de outro jeito, e **passou** — porque a busca de duplicata comparava as + * quatro colunas como string crua. Depois, o teste de envio de remessa não processou. + * + * Este caso mede a borda, e não a régua: o teste do domínio prova que as duas escritas produzem a + * mesma chave; só este prova que o CADASTRO recusa. Entre os dois há o `findByNaturalKey`, que era + * onde o defeito morava. + */ + it('#995 CA2: a mesma conta escrita de outro jeito é RECUSADA como duplicata', async () => { + const legacy = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + // Como o ETL grava: banco sem zeros, agência com o DV embutido, conta com zeros à esquerda. + payload: body({ + bankCode: '7', + agency: '1234-1', + accountNumber: '0088123', + accountDigit: '3', + nickname: 'Conta migrada', + }), + }); + assert.equal(legacy.statusCode, 201, legacy.body); + + // Como o operador digita na tela: banco em 3 posições, agência sem DV, conta sem zeros. + const typed = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ + bankCode: '007', + agency: '1234', + accountNumber: '88123', + accountDigit: '3', + nickname: 'Conta nova', + }), + }); + + assert.equal( + typed.statusCode, + 409, + `a duplicata entrou de novo — a chave natural voltou a comparar string crua: ${typed.body}`, + ); + }); + + // O outro lado, e ele impede a canonização de virar recusa indevida: contas realmente diferentes + // continuam entrando. Sem este caso, uma régua agressiva demais passaria em tudo acima e travaria + // o cadastro legítimo. + it('#995 CA2: conta de OUTRA agência não é lida como duplicata', async () => { + const first = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ agency: '5001', accountNumber: '660011', nickname: 'Agência A' }), + }); + assert.equal(first.statusCode, 201, first.body); + + const second = await handle.app.inject({ + method: 'POST', + url: '/api/v2/financial/cedente-accounts', + headers: { authorization: `Bearer ${WRITER}` }, + payload: body({ agency: '5002', accountNumber: '660011', nickname: 'Agência B' }), + }); + assert.equal(second.statusCode, 201, second.body); + }); + it('CA-US2: POST /cedente-accounts/:id/close → rota existe (≠ 404)', async () => { const id = '11111111-1111-4111-8111-111111111111'; const res = await handle.app.inject({ diff --git a/tests/modules/financial/domain/cedente/natural-key.test.ts b/tests/modules/financial/domain/cedente/natural-key.test.ts new file mode 100644 index 00000000..483f8ac2 --- /dev/null +++ b/tests/modules/financial/domain/cedente/natural-key.test.ts @@ -0,0 +1,163 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +import { + canonicalNaturalKey, + isSameCedenteAccount, +} from '#src/modules/financial/domain/cedente/natural-key.ts'; + +/** + * A CHAVE NATURAL CANÔNICA (#995, bloco A). + * + * O defeito: a 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. + */ +const legacy = { + bankCode: '7', + agency: '1234-1', + accountNumber: '0012345', + accountDigit: '3', +}; + +const typedOnScreen = { + bankCode: '007', + agency: '1234', + accountNumber: '12345', + accountDigit: '3', +}; + +describe('canonicalNaturalKey — a forma que faz duas escritas coincidirem', () => { + /* + * ⚠️ O CASO DE PRODUÇÃO, e o único que a issue descreve com dados. + * + * A conta migrada do legado estava ATIVA. O operador cadastrou outra, com os MESMOS dados + * bancários escritos de outro jeito, e passou — porque nenhuma das quatro strings batia. Depois, + * o teste de envio de remessa não processou. + */ + it('CA2: a conta do legado e a digitada na tela são a MESMA conta', () => { + assert.equal( + canonicalNaturalKey(legacy), + canonicalNaturalKey(typedOnScreen), + 'as duas escritas continuam sendo contas diferentes para o sistema', + ); + assert.ok(isSameCedenteAccount(legacy, typedOnScreen)); + }); + + // ── Banco: SEMPRE 3 dígitos ──────────────────────────────────────────────────────────────── + // + // Não é escolha nossa: é o que o CNAB grava (`num(bankCode, 3)`) e o que a tabela FEBRABAN do + // front documenta como forma canônica do código de compensação. + it('banco: zeros à esquerda são acrescentados até 3 posições', () => { + const base = { agency: '1234', accountNumber: '5', accountDigit: '0' }; + const key = (bankCode: string) => canonicalNaturalKey({ ...base, bankCode }); + + assert.equal(key('7'), key('007')); + assert.equal(key('1'), key('001')); + assert.equal(key('237'), key('237')); + }); + + // ── Agência: só os dígitos, sem zeros à esquerda ─────────────────────────────────────────── + // + // O DV tem coluna própria desde a #856, então separador aqui é resíduo de cadastro antigo. + /* + * ⚠️ O DV É DESCARTADO, NÃO CONCATENADO — e o inverso disto é um defeito real que estes casos + * pegaram durante a escrita. + * + * Uma primeira versão usava `replace(/\D/g, '')` na agência, e `'1234-1'` virava `'12341'`: a + * agência somada ao dígito, exatamente a corrupção que o #856 corrigiu no emissor. `1234-1` é a + * agência `1234`; o `1` é DV e tem coluna própria. + * + * A gramática é `splitCheckDigit`, a MESMA que o ETL usa para decompor a agência legada. + */ + it('agência: o DV é DESCARTADO e os zeros à esquerda somem', () => { + const base = { bankCode: '237', accountNumber: '5', accountDigit: '0' }; + const key = (agency: string) => canonicalNaturalKey({ ...base, agency }); + + assert.equal(key('1234-1'), key('1234'), 'o DV foi concatenado ao número da agência'); + assert.equal(key('1234/5'), key('1234'), 'o DV foi concatenado ao número da agência'); + assert.equal(key('0288'), key('288')); + }); + + // O contraponto: uma agência SEM separador é o número inteiro, não `1234` + `5`. Adivinhar onde + // termina o número é o palpite que a #708 proibiu — sem separador, não há DV a descartar. + it('agência sem separador é o número INTEIRO — nada é descartado por adivinhação', () => { + const base = { bankCode: '237', accountNumber: '5', accountDigit: '0' }; + const key = (agency: string) => canonicalNaturalKey({ ...base, agency }); + + assert.notEqual(key('12345'), key('1234')); + }); + + // ── Conta: sem zeros à esquerda ───────────────────────────────────────────────────────────── + it('conta: zeros à esquerda somem', () => { + const base = { bankCode: '237', agency: '1234', accountDigit: '0' }; + const key = (accountNumber: string) => canonicalNaturalKey({ ...base, accountNumber }); + + assert.equal(key('0012345'), key('12345')); + assert.equal(key('000001'), key('1')); + }); + + // ⚠️ Zero NÃO colapsa em vazio. `'000'` é dado presente e mal preenchido; virar `''` o tornaria + // indistinguível de campo em branco, e duas contas incompletas de contas DIFERENTES passariam a + // ser "a mesma". + it('um valor inteiro de zeros continua sendo um valor, não vazio', () => { + const base = { bankCode: '237', agency: '1234', accountDigit: '0' }; + + assert.notEqual( + canonicalNaturalKey({ ...base, accountNumber: '000' }), + canonicalNaturalKey({ ...base, accountNumber: '' }), + ); + assert.equal( + canonicalNaturalKey({ ...base, accountNumber: '000' }), + canonicalNaturalKey({ ...base, accountNumber: '0' }), + ); + }); + + // O `P` do Bradesco é DV legítimo (manual 4008-523-0096 v16, p. 30) — a caixa não pode separar + // duas escritas do mesmo dígito. + it('dígito: caixa e espaço em volta não fazem diferença', () => { + const base = { bankCode: '237', agency: '1234', accountNumber: '5' }; + const key = (accountDigit: string) => canonicalNaturalKey({ ...base, accountDigit }); + + assert.equal(key('p'), key('P')); + assert.equal(key(' 3 '), key('3')); + }); + + // ── O outro lado: contas DIFERENTES continuam diferentes ─────────────────────────────────── + // + // Sem estes casos, uma canonização agressiva demais (que colapsasse tudo) passaria em tudo acima + // e faria o cadastro recusar contas legítimas. + it('contas realmente diferentes NÃO colapsam', () => { + const base = { bankCode: '237', agency: '1234', accountNumber: '5678', accountDigit: '9' }; + + for (const other of [ + { ...base, bankCode: '341' }, + { ...base, agency: '4321' }, + { ...base, accountNumber: '8765' }, + { ...base, accountDigit: '0' }, + ]) { + assert.ok( + !isSameCedenteAccount(base, other), + `colapsou contas diferentes: ${JSON.stringify(other)}`, + ); + } + }); + + // O separador não pode ser ambíguo: se uma parte pudesse conter `|`, duas chaves distintas + // colidiriam por junção. Todas são dígitos ou um caractere de DV — este caso fixa isso. + it('a junção não é ambígua entre partes vizinhas', () => { + assert.notEqual( + canonicalNaturalKey({ + bankCode: '237', + agency: '12', + accountNumber: '345', + accountDigit: '6', + }), + canonicalNaturalKey({ + bankCode: '237', + agency: '123', + accountNumber: '45', + accountDigit: '6', + }), + ); + }); +}); From 2ec3400e6d91b93c70058f10aadb81a591f5fb4f Mon Sep 17 00:00:00 2001 From: Alessandra Date: Sun, 6 Sep 2026 18:27:30 -0300 Subject: [PATCH 13/13] =?UTF-8?q?fix(financial):=20quatro=20achados=20da?= =?UTF-8?q?=20revis=C3=A3o=20do=20bloco=20A,=20e=20um=20deles=20era=20regr?= =?UTF-8?q?ess=C3=A3o=20minha=20(#995)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 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 --- .../repos/cedente-account-store.drizzle.ts | 14 ++++- .../financial/domain/cedente/natural-key.ts | 52 +++++++++++++++--- .../cedente-account-store.contract.ts | 45 +++++++++++++++ .../domain/cedente/natural-key.test.ts | 55 ++++++++++++++++++- 4 files changed, 155 insertions(+), 11 deletions(-) diff --git a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts index ca083d82..dbda5ab3 100644 --- a/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts +++ b/src/modules/financial/adapters/persistence/repos/cedente-account-store.drizzle.ts @@ -85,9 +85,21 @@ export const createDrizzleCedenteAccountStore = ( const wanted = canonicalNaturalKey(key); for (const row of rows) { const mapped = toDomain(row); + // ⚠️ LINHA INMAPEÁVEL É PULADA, NÃO DERRUBA A BUSCA — e a diferença é de RAIO DE DANO. + // + // Enquanto o filtro era SQL, só a linha candidata era mapeada: uma linha ruim em outro + // lugar da tabela era invisível para esta operação. Com a varredura, abortar na primeira + // falha faz UMA linha legada com `type` fora do union (ou status, ou id não-UUID) derrubar + // TODO cadastro de conta com 503 — a checagem de duplicata morre antes de chegar na linha + // que procurava, e o operador não tem como saber por quê. + // + // Pular preserva o raio anterior: a busca responde sobre as linhas que ela consegue ler, e + // a linha ruim fica no log para quem for consertá-la. O custo teórico é a duplicata passar + // se a colisão for JUSTAMENTE com a linha inmapeável — e nesse caso o cadastro já está + // quebrado de um jeito que esta função não conserta. if (!mapped.ok) { logStore('findByNaturalKey:map', mapped.error); - return err('cedente-account-store-unavailable'); + continue; } if (canonicalNaturalKey(mapped.value) === wanted) return ok(mapped.value); } diff --git a/src/modules/financial/domain/cedente/natural-key.ts b/src/modules/financial/domain/cedente/natural-key.ts index 7aed9eae..3e8c73be 100644 --- a/src/modules/financial/domain/cedente/natural-key.ts +++ b/src/modules/financial/domain/cedente/natural-key.ts @@ -5,14 +5,21 @@ // A duplicata comparava as quatro colunas como STRING CRUA. A mesma conta bancária, escrita de outro // jeito, entrava de novo — e foi o que aconteceu 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 -// accountDigit '' accountDigit '3' diferentes +// legado (ETL) digitado na tela o sistema via esta régua +// bankCode '7' bankCode '007' diferentes MESMA +// agency '1234-1' agency '1234' diferentes MESMA +// accountNumber '0012345' accountNumber '12345' diferentes MESMA +// accountDigit '' accountDigit '3' diferentes ⚠️ ver abaixo // // Para o banco é UMA conta. O segundo cadastro passou, e o teste de remessa não processou. // +// ⚠️ O DÍGITO DA CONTA VAZIO NÃO É UNIFICADO POR SI, e vale dizer o que a régua faz e o que não faz. +// Quando o DV está EMBUTIDO no número (`'0088123-3'` com a coluna vazia), ele é recuperado dali e a +// chave bate com `'88123'` + `'3'`. Mas coluna vazia SEM nada embutido continua diferente de um +// dígito preenchido — e é o certo: colapsar "sem dígito" com "qualquer dígito" faria contas de +// dígitos distintos virarem a mesma. Se existir linha legada assim, unificá-la é decisão de dado, +// não de código: o operador informa o dígito pela tela e as duas escritas passam a coincidir. +// // ## As formas, definidas pela P.O. (06/09/2026) // // · banco — SEMPRE 3 dígitos com zeros à esquerda (`'7'` → `'007'`). Não é escolha nossa: é o @@ -68,6 +75,30 @@ const agencyBase = (raw: string): string => { return split === null ? digitsOnly(raw) : digitsOnly(split.base); }; +// A CONTA sofre do MESMO problema da agência, e a assimetria era defeito — não desenho. +// +// O ETL que embutiu o DV em `agency` embutiu na CONTA também: `'0088123-3'` com `account_digit` +// vazio. `digitsOnly` daria `'00881233'`, concatenando conta e dígito, enquanto o operador digita +// `'88123'` + `'3'` — e as duas escritas da MESMA conta continuariam divergindo. +// +// Quando o número traz o DV embutido E a coluna do dígito está vazia, o dígito é RECUPERADO dali. +// Só nesse caso: com a coluna preenchida, ela manda (o operador é a fonte mais recente); e sem +// separador não há dígito a recuperar — adivinhar onde o número termina é o palpite que a #708 +// proibiu. +const accountParts = ( + accountNumber: string, + accountDigit: string, +): Readonly<{ number: string; digit: string }> => { + const digit = accountDigit.trim().toUpperCase(); + const split = splitCheckDigit(accountNumber.trim()); + + if (split === null) return { number: digitsOnly(accountNumber), digit }; + return { + number: digitsOnly(split.base), + digit: digit === '' ? (split.digit ?? '') : digit, + }; +}; + // Zeros à esquerda somem, mas o valor inteiro de zeros NÃO vira string vazia: `'000'` é um dado // presente e mal preenchido, e colapsá-lo em `''` o tornaria indistinguível de campo em branco. const withoutLeadingZeros = (raw: string): string => raw.replace(/^0+(?=.)/, ''); @@ -79,12 +110,15 @@ const withoutLeadingZeros = (raw: string): string => raw.replace(/^0+(?=.)/, '') * —, então não há como duas chaves diferentes colidirem por ambiguidade de junção. */ export const canonicalNaturalKey = (parts: CedenteNaturalKeyParts): string => { - const bankCode = digitsOnly(parts.bankCode).padStart(BANK_CODE_WIDTH, '0'); + // ⚠️ TIRA os zeros ANTES de completar. `padStart` só completa, nunca corta: sem o + // `withoutLeadingZeros`, `'0237'` continuaria `'0237'` e não bateria com `'237'` — e a borda + // aceita os dois (`z.string().min(1).max(10)`). Um extrato legado de largura fixa grava `'0237'`; + // o operador digita `237`. Era a única parte que só completava, e a assimetria era o defeito. + const bankCode = withoutLeadingZeros(digitsOnly(parts.bankCode)).padStart(BANK_CODE_WIDTH, '0'); const agency = withoutLeadingZeros(agencyBase(parts.agency)); - const accountNumber = withoutLeadingZeros(digitsOnly(parts.accountNumber)); - const accountDigit = parts.accountDigit.trim().toUpperCase(); + const account = accountParts(parts.accountNumber, parts.accountDigit); - return `${bankCode}|${agency}|${accountNumber}|${accountDigit}`; + return `${bankCode}|${agency}|${withoutLeadingZeros(account.number)}|${account.digit}`; }; /** As duas escritas apontam para a mesma conta bancária? */ diff --git a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts index d33ea164..6f6537d2 100644 --- a/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts +++ b/tests/modules/financial/adapters/persistence/cedente-account-store.contract.ts @@ -120,6 +120,51 @@ export const cedenteAccountStoreContract = ( assert.equal(found.value.nextNsa, 4); }); + /* + * ⚠️ A BUSCA POR CHAVE NATURAL É CANÔNICA, E ISTO PRECISA VALER NOS DOIS ADAPTERS (#995, bloco A). + * + * O caso vive no contrato — e não só na borda — porque a borda de teste roda no fake in-memory: + * um caso só lá deixaria o caminho DRIZZLE sem cobertura, e é justamente nele que a comparação + * deixou de ser `where` em SQL e passou a ser varredura com a régua do domínio. Aqui ele roda + * também contra MySQL real, pelo consumidor de integração. + * + * O par abaixo é o de produção (06/09): a mesma conta bancária escrita como o ETL grava e como o + * operador digita. + */ + it('#995: findByNaturalKey acha a conta escrita de outro jeito — comparação CANÔNICA', async () => { + const account = buildAccount({ convenio: `${CONTRACT_CONVENIO_PREFIX}7001` }); + assert.ok(isOk(await store.save(account))); + + const found = await store.findByNaturalKey({ + // Mesma conta, escrita como o legado a grava: banco sem zeros, conta com zeros à esquerda. + bankCode: String(Number(account.bankCode)), + agency: account.agency, + accountNumber: `00${account.accountNumber}`, + accountDigit: account.accountDigit, + }); + + assert.ok(isOk(found)); + assert.notEqual(found.value, null, 'a duplicata escrita de outro jeito não foi encontrada'); + if (found.value !== null) assert.equal(found.value.id, account.id); + }); + + // O contraponto, no mesmo lugar: conta REALMENTE diferente continua não sendo encontrada. Sem + // ele, uma canonização agressiva demais passaria no caso acima e recusaria cadastro legítimo. + it('#995: findByNaturalKey NÃO acha conta de outra agência', async () => { + const account = buildAccount({ convenio: `${CONTRACT_CONVENIO_PREFIX}7002` }); + assert.ok(isOk(await store.save(account))); + + const found = await store.findByNaturalKey({ + bankCode: account.bankCode, + agency: '8888', + accountNumber: account.accountNumber, + accountDigit: account.accountDigit, + }); + + assert.ok(isOk(found)); + assert.equal(found.value, null); + }); + it('save em id existente atualiza os campos editáveis', async () => { const account = await seed(); diff --git a/tests/modules/financial/domain/cedente/natural-key.test.ts b/tests/modules/financial/domain/cedente/natural-key.test.ts index 483f8ac2..160448bd 100644 --- a/tests/modules/financial/domain/cedente/natural-key.test.ts +++ b/tests/modules/financial/domain/cedente/natural-key.test.ts @@ -56,6 +56,18 @@ describe('canonicalNaturalKey — a forma que faz duas escritas coincidirem', () assert.equal(key('237'), key('237')); }); + // ⚠️ `padStart` só COMPLETA, nunca corta. Uma primeira versão não tirava os zeros antes de + // completar, e `'0237'` continuava `'0237'`, sem bater com `'237'` — a borda aceita os dois + // (`z.string().min(1).max(10)`). Um extrato legado de largura fixa grava `'0237'`; o operador + // digita `237`, e a duplicata que esta chave existe para pegar passava de novo. + it('banco: excesso de zeros à esquerda também colapsa — a forma é IDEMPOTENTE', () => { + const base = { agency: '1234', accountNumber: '5', accountDigit: '0' }; + const key = (bankCode: string) => canonicalNaturalKey({ ...base, bankCode }); + + assert.equal(key('0237'), key('237')); + assert.equal(key('00007'), key('007')); + }); + // ── Agência: só os dígitos, sem zeros à esquerda ─────────────────────────────────────────── // // O DV tem coluna própria desde a #856, então separador aqui é resíduo de cadastro antigo. @@ -87,7 +99,7 @@ describe('canonicalNaturalKey — a forma que faz duas escritas coincidirem', () assert.notEqual(key('12345'), key('1234')); }); - // ── Conta: sem zeros à esquerda ───────────────────────────────────────────────────────────── + // ── Conta: sem zeros à esquerda, e sem o DV concatenado ──────────────────────────────────── it('conta: zeros à esquerda somem', () => { const base = { bankCode: '237', agency: '1234', accountDigit: '0' }; const key = (accountNumber: string) => canonicalNaturalKey({ ...base, accountNumber }); @@ -96,6 +108,47 @@ describe('canonicalNaturalKey — a forma que faz duas escritas coincidirem', () assert.equal(key('000001'), key('1')); }); + /* + * ⚠️ A CONTA SOFRE DO MESMO PROBLEMA DA AGÊNCIA, e a assimetria era defeito, não desenho. + * + * O ETL que embutiu o DV em `agency` embutiu na CONTA também. `digitsOnly('0088123-3')` daria + * `'00881233'` — conta e dígito colados —, enquanto o operador digita `'88123'` + `'3'`. As duas + * escritas da MESMA conta continuariam divergindo, e a duplicata passaria. + * + * O dígito é RECUPERADO do número só quando a coluna própria está VAZIA: com ela preenchida, ela + * manda (é a fonte mais recente). + */ + it('conta: DV embutido é separado, e recuperado quando a coluna do dígito está vazia', () => { + const legacyEmbedded = { + bankCode: '237', + agency: '1234', + accountNumber: '0088123-3', + accountDigit: '', + }; + const typed = { + bankCode: '237', + agency: '1234', + accountNumber: '88123', + accountDigit: '3', + }; + + assert.ok( + isSameCedenteAccount(legacyEmbedded, typed), + 'o DV embutido na conta foi concatenado ao número', + ); + }); + + // A coluna preenchida VENCE o que está embutido — ela é o dado mais recente, e deixar o embutido + // mandar faria uma correção de dígito pela tela não surtir efeito na comparação. + it('conta: a coluna do dígito, quando preenchida, vence o embutido', () => { + const base = { bankCode: '237', agency: '1234', accountNumber: '88123-3' }; + + assert.notEqual( + canonicalNaturalKey({ ...base, accountDigit: '9' }), + canonicalNaturalKey({ ...base, accountDigit: '3' }), + ); + }); + // ⚠️ Zero NÃO colapsa em vazio. `'000'` é dado presente e mal preenchido; virar `''` o tornaria // indistinguível de campo em branco, e duas contas incompletas de contas DIFERENTES passariam a // ser "a mesma".