Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ body:json {
{
"name": "Forn E2E Bruno",
"email": "forn-e2e-bruno@example.com",
"cnpj": "11222333000181",
"document": "11222333000181",
"corporateName": "Forn E2E Bruno LTDA",
"fantasyName": "Forn E2E",
"serviceCategory": "INFORMATICA",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ tests {
expect(res.getBody().id).to.equal(bru.getVar("supplierCreatedId"));
});

test("CA3 — cnpj esta correto", function () {
expect(res.getBody().cnpj).to.equal("11222333000181");
test("CA3 — document esta correto e o fornecedor e PJ (#1022)", function () {
expect(res.getBody().document).to.equal("11222333000181");
expect(res.getBody().personType).to.equal("PJ");
});

test("#1022 — alias deprecated cnpj espelha document", function () {
expect(res.getBody().cnpj).to.equal(res.getBody().document);
});

test("CA3 — supplier esta ativo apos criacao", function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ body:json {
{
"name": "Forn E2E Bruno Atualizado",
"email": "forn-e2e-bruno-updated@example.com",
"cnpj": "11222333000181",
"document": "11222333000181",
"corporateName": "Forn E2E Bruno LTDA Atualizado",
"fantasyName": "Forn E2E Updated",
"serviceCategory": "CONSULTORIA",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
[← Voltar para ADRs](./README.md)

# ADR-0070: Fornecedor pessoa física — o documento do Fornecedor passa a ser CPF **ou** CNPJ, e a PF não tem razão social nem nome fantasia

- **Status:** Accepted
- **Date:** 2026-10-01
- **Deciders:** Tech Lead (Gabriel) + P.O do cliente (decisão de produto na issue e na conversa de 2026-10-01)
- **Supersedes (parcial):** [ADR-0031](./0031-partners-registry-module.md) — na linha de `par_suppliers` da tabela de schemas (`cnpj unique`): a chave natural do Fornecedor deixa de ser só CNPJ e o campo sensível da edição passa de `cnpj` a `document`. `Financier` e `Act` **não mudam** — continuam com o `Cnpj` do kernel e recusam CPF. O resto do ADR-0031 segue vigente.
- **Relates:** [#1022](https://github.com/ERP-Bem-Comum/core-api/issues/1022) · [ADR-0043](./0043-partners-supplier-integration-events.md) (payload de integração já usa `document`) · [ADR-0044](./0044-cnpj-alphanumeric-kernel.md) (CNPJ alfanumérico)

## Contexto

O cliente tem fornecedores pessoa física, pagos por RPA. O cálculo do RPA já existe no Contas a Pagar;
faltava o cadastro: `Supplier.cnpj` era o VO `Cnpj` do kernel, a coluna `par_suppliers.cnpj` era
`varchar(14)` UNIQUE e o mapper reidratava com `Cnpj.parse` — uma linha com CPF quebraria na leitura.

Pessoa física não tem razão social nem nome fantasia. A pergunta de modelagem é **como representar
um campo que, para um dos dois tipos de pessoa, não existe**.

## Decisão

### 1. Identidade PF × PJ é uma union discriminada no domínio

```ts
type SupplierIdentity =
| { personType: 'individual'; document: { kind: 'cpf'; value: Cpf } }
| { personType: 'company'; document: { kind: 'cnpj'; value: Cnpj }; corporateName: string; fantasyName: string };
```

Na PF, `corporateName` e `fantasyName` **não existem no tipo** — não é que existam vazios. O
compilador impede ler razão social de quem não a tem sem antes perguntar o `personType`, e o
`switch` exaustivo obriga cada consumidor (DTO, CSV, busca, mapper) a decidir o que mostra para PF.

O `SupplierDocument` vive em `partners/domain/supplier/`, **não no kernel**: só o Fornecedor aceita os
dois documentos. O `Cnpj` do kernel não muda.

### 2. O tipo de pessoa é derivado do documento, nunca gravado

Um campo `personType` gravado permitiria o estado "PF com CNPJ". Derivado do documento (11 dígitos →
CPF/PF; letra ou 14 caracteres → CNPJ/PJ), esse estado não é representável. A borda HTTP expõe
`personType: 'PF' | 'PJ'` calculado.

### 3. `NULL` só nas bordas; nenhum valor sentinela

Na coluna MySQL e no JSON, a ausência de razão social/nome fantasia da PF é `NULL`/`null`, e o banco
amarra isso ao documento:

```sql
CHECK ((CHAR_LENGTH(document) = 11) = (corporate_name IS NULL)) -- idem fantasy_name
```

**Alternativa recusada: valor sentinela** (`-1`, `00`, string reservada) no lugar de `null`. Foi
proposta para evitar que um `null` "corrompido" virasse `undefined` ou `NaN` no caminho. A premissa
não se sustenta, e o sentinela traria defeito concreto:

- Em JS, `null` é um valor primitivo fixo, não um ponteiro para memória não inicializada; o `NULL` do
MySQL é estado definido pelo padrão SQL e o driver `mysql2` o entrega como `null`; no JSON, `null`
faz ida e volta exata. Quem some na serialização é `undefined`, não `null`.
- Corrupção de memória ou de disco atinge qualquer valor por igual — um `-1` corrompido também vira
outra coisa. O sentinela não protege contra ela.
- Os dois campos são **texto**: o sentinela seria a string `"-1"`, que passaria pelo `CHECK`
(`IS NULL` falso) e chegaria à busca, ao CSV, à resposta HTTP e à tela se algum consumidor esquecer
de filtrá-lo — um magic value com outro nome.
- O objetivo real ("o código nunca fica em dúvida se o valor existe") é entregue melhor pela union do
§1: dentro do domínio não há nem `null` nem sentinela.

O resto do módulo já representa ausência com `null` na borda (`collaborator/types.ts:35-50`).

### 4. PF com razão social/nome fantasia preenchido é **recusado**, não ignorado

`supplier-corporate-name-not-allowed-for-pf` / `supplier-fantasy-name-not-allowed-for-pf` (422).
Descartar em silêncio algo que o usuário digitou esconde um erro de cadastro (CPF digitado no lugar
do CNPJ, por exemplo). Branco (`""`, espaços) conta como ausente, porque a tela desativa os campos e
pode enviá-los vazios. Para PJ, os dois continuam obrigatórios.

### 5. Trocar PF ↔ PJ é trocar o campo sensível

O campo vital da edição passa de `cnpj` a `document`. Trocar PF ↔ PJ necessariamente troca o
documento, então exige `supplier:edit-sensitive` sem regra adicional. Ao virar PJ, razão social e
nome fantasia passam a ser obrigatórios.

### 6. Contrato HTTP v1: aditivo, com alias por um ciclo

O recurso `/api/v1/suppliers` é contrato congelado ([ADR-0033](./0033-api-versioning-v1-legacy-mirror.md)),
então a mudança é **aditiva** e o nome antigo sobrevive por um ciclo, para o backend subir antes do
front sem quebrar a tela atual:

- **Entrada (POST/PUT):** `document` (11 ou 14 caracteres). `cnpj` aceito como alias deprecated;
exige-se um dos dois, iguais se vierem ambos.
- **Resposta:** `document` + `personType`; `cnpj` segue como alias deprecated de `document`;
`corporateName`/`fantasyName` passam a `string | null`.
- **Códigos de erro renomeados:** `invalid-cnpj` → `invalid-supplier-document`;
`*-cnpj-duplicate` → `*-document-duplicate`.

A remoção do alias `cnpj` é decisão do próximo ciclo, quando o front (`web-app` spec
`117-fornecedor-pessoa-fisica`) estiver em produção consumindo `document`/`personType`.

## Consequências

- **Migration `0020`** (partners): `RENAME COLUMN cnpj TO document` (continua `varchar(14)`), índice
`par_suppliers_document_idx`, `corporate_name`/`fantasy_name` nullable e os dois CHECKs. Sem
backfill: toda linha existente tem CNPJ de 14 caracteres e os dois nomes preenchidos.
- **Integração `partners → financial`:** nenhuma mudança de contrato. O payload já se chamava
`document` desde o ADR-0043, e o evento de domínio `SupplierRegistered` nunca é serializado — o
payload sai do snapshot do agregado. Não há evento antigo no outbox com `cnpj` a aceitar.
- **CNAB/VAN:** sem mudança de código. `financial/domain/payout/inscription.ts` já deriva o tipo de
inscrição do tamanho (11 → `1`).
- **Leitura de nota (OCR):** `findSupplierIdByCnpj` vira `findSupplierIdByDocument` e resolve CPF
também — o emitente de um RPA é pessoa física. ⚠️ Risco aceito: a cascata do leitor de PDF tem
fallbacks sobre o texto inteiro, e um CPF que não é do emitente (tomador, representante) e que
coincida com o de um fornecedor PF passa a **pré-selecioná-lo**. Antes, `Cnpj.parse` descartava
todo valor de 11 posições. É pré-seleção, revisável na tela, não lançamento; se aparecer na
prática, a correção é restringir o resolver ao campo do emitente, não recusar CPF.
- **ETL legada:** a coluna legada `cnpj` pode trazer CPF; antes ia para quarentena (`CnpjInvalid`),
agora entra como PF. O legado tinha razão social/nome fantasia `NOT NULL`, então a PF chega com os
dois preenchidos à força; o ACL os **descarta** para PF, porque ali eles são enchimento de
formulário, não dado da pessoa. **Exceção: CPF ambíguo vai para quarentena** (`ExcludedByDecision`,
ADR-0070) — se as 11 posições completadas com zeros formam um CNPJ válido, pode ser um CNPJ
legado que perdeu os zeros à esquerda (pelos pesos do módulo 11, só acontece com documento
iniciado em `00`), e importá-lo como PF descartaria a razão social real. Se a ETL já rodou e pôs fornecedores PF em quarentena, eles
precisam ser recarregados depois desta entrega (#1022 §6).
- **CSV:** a coluna do documento passa a se chamar `CPF/CNPJ`; razão social e nome fantasia saem
vazios na PF.
- ⚠️ **Mudança de status perceptível:** POST/PUT de PJ **sem** `corporateName` passa de 400 (Zod) a
422 (`supplier-corporate-name-required`, domínio), porque o shape não pode mais exigir o campo — quem
decide é o documento.
1 change: 1 addition & 0 deletions handbook/architecture/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ Um **ADR (Architecture Decision Record)** é um documento curto que captura uma
| [0067](./0067-typescript-7-side-by-side-supersedes-0009-language.md) | **TypeScript 7 nativo compila; o TS 6 fica só para o `typescript-eslint`** — `supersedes` **parcial** de [0009](./0009-node-24-typescript-6-with-7-roadmap.md), seções "Linguagem" (`:32-34`) e "Plano de migração" (`:36-42`). O gatilho literal do 0009 (`:90-96`) disparou: **TS 7.0 é GA desde 08/07/2026**. Mas duas previsões dele estavam erradas e ficam ditas: "ajustes mínimos" não descreve o port — **nenhuma** versão do `typescript-eslint` aceita TS 7 (peer `>=4.8.4 <6.1.0` no `latest` 8.68.0 **e** no canary; sem v9 publicada; issue [#10940](https://github.com/typescript-eslint/typescript-eslint/issues/10940) OPEN, parada desde 09/07) —, e o "CI roda `tsc` e `tsgo` em paralelo" (`:39`) **nunca foi implementado** em 4 meses, então é **revogado**, não repetido. Side-by-side por alias: `typescript` → `npm:@typescript/typescript6`, `@typescript/native` → `npm:typescript@^7.0.2`. **`@typescript/native-preview` sai** (descontinuado pela Microsoft). ✅ Nenhum breaking do TS 7 atinge o `tsconfig` (medido campo a campo: `ES2024`, `NodeNext`, sem `baseUrl`, `types`/`strict` explícitos) — dividendo do "strict total desde o dia 1". ⚠️ Risco declarado: template literal com emoji muda tipo inferido **sem erro** (TS 6 conta 4, TS 7 conta 3) — exposição **medida hoje: nula**, o repo não tem template literal type algum. ⚠️ Custo consciente: o alias só existe até `6.0.2` contra `6.0.3` instalado. Instalação **respeita a quarentena** de 24h (`minimumReleaseAgeStrict`) | Accepted | 2026-08-25 |
| [0068](./0068-env-fail-fast-every-environment.md) | **Toda variável de ambiente lida é obrigatória, em TODO ambiente** — ausente ou recusada, o processo não sobe; e `X_DRIVER=memory` deixa de ser valor aceito. Fecha a [#799](https://github.com/ERP-Bem-Comum/core-api/issues/799) e **revoga o FR-007** do [#456](https://github.com/ERP-Bem-Comum/core-api/issues/456), que fazia `memory` declarado subir em produção "sem falhar". O fato que decide não está no código: **as envs de homologação e produção são postas à mão, na console da AWS, por um funcionário da Codebit** — degradar ali não protege ninguém, porque o sinal não chega a quem poderia corrigir; e local que degrada deixa de espelhar hml/prod, que é como um defeito de configuração sobrevive até o deploy. Rejeita a variante mais estrita (remover `driver: 'memory'` também dos seis tipos por módulo) por ser **cara e inútil**: custo medido de **179** arquivos de teste migrando para integração e `pnpm test` passando a exigir Docker, para trancar uma porta já trancada — em hml/prod a única entrada do in-memory é a env, e não existe caller que passe `{ driver: 'memory' }` fora do `server.ts` e dos testes ([Inquiry-0034](../../inquiries/0034-in-memory-fora-de-local-custo-na-piramide.md)). O adapter em memória **sobrevive como double de teste injetado** — outra fronteira, outra coisa. ⚠️ Custo declarado: todo dev passa a precisar declarar as envs, e um ambiente sem elas para de subir | Accepted | 2026-08-31 |
| [0069](./0069-approval-policy-follows-rbac-bypass-supersedes-0052-partial.md) | **A alçada de aprovação segue o `AUTH_RBAC_MODE=bypass`** — `supersedes` **parcial** de [0052](./0052-rbac-bypass-flag.md): o bypass deixa de parar na borda HTTP e alcança a permissão `payable:approve` lida pela `approval-policy` do domínio. O bypass vinha sendo aplicado pela metade, por **três** camadas que decidem sobre permissão e das quais só duas o honravam: `authorize` vira no-op ✅, o `GET /me` anuncia o **catálogo inteiro** (`list-user-permissions.ts:35`) ✅, e a `approval-policy` (`approval-policy.ts:27`) lia `canApprove` do banco cru e recusava ❌. Medido em 09/09 no ambiente local: o `/me` de um usuário do ETL legado devolvia **47 permissões, incluindo `payable:approve`**; o banco lhe dava **uma**, e não era essa — ele tomava uma recusa que **contradizia o que o `/me` acabara de lhe dizer**, depois de o `authorize` no-op o deixar entrar até o fundo. Aplicado como **decorator** (`withRbacBypass`) composto num ponto só, no composition root do `financial`; o `server.ts` traduz o modo num booleano e o `financial` não passa a conhecer `RbacMode`. ⚠️ Alcança **só o ato de aprovar** (`depsForApprove`): o mesmo port responde a outra pergunta em `saveDocument`/`submitDraft` — se o `approverRef` **indicado** tem alçada —, e essa é **roteamento**, segue enforçada. Compor no `deps` compartilhado, que é o caminho óbvio, faria a indicação **gravar** `approverRef` de quem não é aprovador, e a linha **sobrevive ao religar** — dano que o #634 não desfaz. Continuam valendo sob bypass: `approver-not-found` para usuário inexistente (o bypass afrouxa **permissão**, nunca a existência do sujeito), o **teto** de quem tem papel com alçada (#299/#609) e o `list` de candidatos da cascata (`escalate` é **roteamento de negócio**, não controle de acesso). ⚠️ **Custo aceito, e é o principal: sob bypass todo autenticado aprova qualquer valor** — quem não tem papel aprovador tem teto `null`, e `null` é **SEM TETO** pela regra binária do #299 (`user-read.drizzle.ts:42` + `approval-policy.ts:28-31`), então "o teto continua valendo" **não protege** a população que esta decisão libera. Reabre, enquanto o bypass durar, o buraco que o #609 fechou — e **vale em produção** (linha marcada `← religar` no `server.ts`, risco assumido por escrito no #634). **Gatilho de reversão: o #634 — e ele custa DUAS linhas, não zero:** são duas marcas `← religar`, o `rbacMode` fixado e o `rbacBypass: true`; apagar só a primeira religa a rota e o `/me` e **deixa a policy do domínio afrouxada**, sem que erro de tipo, teste ou lint acusem. **Alternativa recusada** pelo dono: consertar o `/me` subtraindo `payable:approve` do catálogo — sob bypass a promessa é *"todo autenticado é super-usuário"*, e uma regra de domínio que segue cobrando permissão é exceção à promessa, não correção dela. ⚠️ **Não alcança** outras policies de domínio que leiam permissão do banco; hoje esta é a única identificada | Accepted | 2026-09-09 |
| [0070](./0070-supplier-individual-cpf-supersedes-0031-partial.md) | **Fornecedor pessoa física: o documento do Fornecedor passa a ser CPF ou CNPJ** — `supersedes` **parcial** de [0031](./0031-partners-registry-module.md) na chave de `par_suppliers` (`cnpj` → `document`) e no campo sensível da edição; `Financier` e `Act` seguem só com CNPJ. A identidade é **union discriminada** no domínio (`individual` × `company`): na PF, razão social e nome fantasia **não existem no tipo**. O tipo de pessoa é **derivado** do documento, nunca gravado — um campo próprio permitiria "PF com CNPJ". `NULL` só nas bordas (coluna e JSON), amarrado ao documento por `CHECK ((CHAR_LENGTH(document) = 11) = (corporate_name IS NULL))`. **Alternativa recusada: valor sentinela** (`-1`, `00`) no lugar de `null` — `null` em JS é primitivo fixo e faz ida e volta exata no `mysql2` e no JSON; o sentinela seria a string `"-1"`, passaria pelo `CHECK` e vazaria para busca, CSV e tela. PF com razão social preenchida é **recusada** (422), não ignorada; branco conta como ausente. HTTP v1 **aditivo**: `document` + `personType` (`PF`/`PJ`), `cnpj` mantido como alias deprecated por um ciclo na entrada e na resposta. ⚠️ PJ sem `corporateName` passa de 400 (Zod) a 422 (domínio). ETL: CPF na coluna legada `cnpj` deixa de ir para quarentena e entra como PF, com os nomes forçados pelo `NOT NULL` legado descartados | Accepted | 2026-10-01 |
| [0071](./0071-s3-dev-silo-fork-supersedes-0019-partial.md) | **O S3 de dev/teste passa a ser o fork Silo** — `supersedes` **parcial** de [0019](./0019-document-storage-s3-with-minio-dev.md), só na **fonte da imagem**: a MinIO apagou `minio/minio` e `minio/mc` do Docker Hub em 2026-09-11, o espelho `quay.io/minio` exige login, e o `integration` falha todo dia na `main` desde 2026-09-12 ([#1026](https://github.com/ERP-Bem-Comum/core-api/issues/1026)). O compose passa a `pgsty/silo` + `pgsty/mc`, pinados por digest — fork **mantido**, **AGPLv3** como o MinIO, e **drop-in**: mesmas envs `MINIO_*`, rotas `/minio/*`, API S3. Escolhido entre seis hipóteses testadas em container isolado contra as quatro suítes reais do CI: Bitnami legacy (congelada, sem patches, não drop-in), MinIO compilado do fonte (upstream arquivado — viraríamos mantenedores de segurança), RustFS e SeaweedFS (Apache-2.0, passam no S3, mas exigem refazer serviço e bootstrap). ⚠️ Custo aceito: **bus factor baixo** (um mantenedor principal). Gatilho de saída: RustFS, com ADR próprio. Produção (AWS S3) não muda | Accepted | 2026-10-01 |

### Notas de numeração
Expand Down
8 changes: 7 additions & 1 deletion scripts/etl/diagnostics/check-duplicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ type DuplicateCheck = Readonly<{

// As chaves que o destino impõe como UNIQUE (confirmadas na integração partners/auth).
const CHECKS: readonly DuplicateCheck[] = [
{ table: 'suppliers', column: 'cnpj', normalize: 'digits', targetUnique: 'par_suppliers.cnpj' },
// Coluna legada `cnpj` (pode trazer CPF) → `par_suppliers.document` desde a migration 0020 (#1022).
{
table: 'suppliers',
column: 'cnpj',
normalize: 'digits',
targetUnique: 'par_suppliers.document',
},
{ table: 'financiers', column: 'cnpj', normalize: 'digits', targetUnique: 'par_financiers.cnpj' },
{
table: 'collaborators',
Expand Down
Loading
Loading