Lição 3 de 13: Zod nas bordas
- Função
validateCpf(ouvalidateCnpj) em um helper delib/ - Schema Zod definindo a forma do payload da sua API
- Pelo menos 1 teste rodando que confirma CPF inválido é rejeitado
- Commit
feat(validation): zod schema for [recurso] + cpf validator
Como o CEAP filtrou R$ 1.5 milhão de ruído na borda
O CEAP cruza CNAE (atividade econômica registrada na Receita) com categoria de despesa pra detectar fornecedores cobrando por serviço fora do escopo declarado. A primeira versão do mapeamento gerou centenas de falsos positivos. Trecho real de INVESTIGATION.md:
v3.1 Mapping Update. Added to Category 1 (Office Maintenance) to remove false positives:
- 84 — Public Administration (municipalities rent office space to deputies)
- 94 — Associations/Unions (rent space to deputies)
- 85 — Education (schools/universities rent space)
False Positives Successfully Removed:
- Municipalities (CNAE 84) — R$ 445k removed
- Associations/unions (CNAE 94) — R$ 590k removed
- Education institutions (CNAE 85) — R$ 105k removed
A validação na (filtrar CNAEs legítimos antes de classificar como suspeito) economizou R$ 1,5 milhão em falsos positivos. Sem essa camada, a análise sinalizaria prefeituras como "fraude" e queimaria a credibilidade com os auditores do TCU.
A lógica é a mesma da validação que você vai criar: regra explícita no ponto de entrada, erro específico quando não entra, resto do sistema confia que o que passa é válido. No CEAP a regra de entrada classifica fraude; no seu produto ela valida o CPF do cliente.
Por que validar na borda
Toda entrada externa vem de três lugares: formulário do usuário, API route que recebe POST/PUT, ou resposta de API de terceiro (Receita, Stripe, CEP). Sem porta de entrada, o código confia que tudo está OK e passa pro banco. Quando o banco rejeita, o erro estoura no fundo da stack: 20 minutos rastreando de onde veio.
Com validação na borda, o erro estoura no primeiro ponto: a API rejeita o request com erro claro antes de qualquer outra coisa rodar. Você sabe onde o problema é (sempre na borda), o que foi rejeitado (qual campo), e não precisa de checagem defensiva em cada função interna.
Pra produto brasileiro, isso vira ainda mais crítico: CPF/CNPJ inválido no banco volta como problema 6 meses depois, quando você tenta emitir nota fiscal pra esse cliente. Melhor pegar na hora.
Passo 1 — Crie a função de validação de CPF/CNPJ
Validar CPF é verificar o dígito verificador (fórmula da Receita Federal que detecta digitação errada ou número inventado), não só conferir tamanho.
Cole no Claude:
Crie um helper de validação de CPF/CNPJ em `lib/validators/`. Requisitos:
1. Função `validateCpf(cpf: string): boolean` — aceita com ou sem máscara, retorna true se válido.
2. Função `validateCnpj(cnpj: string): boolean` — mesma coisa pra CNPJ.
3. Função `cleanDocument(doc: string): string` — remove pontuação, retorna só dígitos.
4. Testes em arquivo `*.test.ts` ao lado: CPF válido conhecido, CPF inválido óbvio ("11111111111"), CPF com máscara, vazio, com letra. Idem CNPJ.
Mostre o diff antes de criar. Use TypeScript strict.Antes de aprovar, aplica o check 3 do /work (L01): a implementação existe ou foi inventada?
A regra do dígito verificador que você usou — é a regra oficial da Receita Federal? Confirma com referência (link ou descrição do algoritmo) ou marca como "implementação padrão, verificar contra fonte oficial antes de produção".Se cita a regra (multiplicação por 10, 9, 8, ... e mod 11), OK. Se admite "implementação comum mas não verificada", também OK: você sabe o que tem e pode validar contra um CPF real conhecido nos testes.
Aprove. Roda:
Roda o teste da validação de CPF/CNPJ. Mostra o output.Testes precisam passar. Se falhar, Claude corrige. Não avance antes do verde.
Passo 2 — Crie o Zod schema do recurso
Você vai escrever um objeto que descreve "essa request precisa ter os campos X, Y, Z; X é string com regra Y; Y é número positivo; Z é opcional." O Zod transforma essa descrição em uma função que recebe dados crus e devolve "ok" ou "inválido + por quê."
Cole no Claude:
Crie um Zod schema em `lib/schemas/` para o payload de criação de . Requisitos:
1. Importe `z` de `zod` e `validateCpf` (ou `validateCnpj`) do helper do Passo 1.
2. Defina `createSchema` com os campos da tabela (consulte o schema da L02).
3. Campos de documento usam `.refine(validateCpf, { message: 'CPF inválido' })` — mensagem em pt-BR.
4. Valores monetários: aceita como número de centavos (`z.number().int().positive()`); rejeita float.
5. Exporte o tipo TypeScript inferido: `export type CreateInput = z.infer<typeof createSchema>`.
Mostre o diff. Inclua 1 teste: schema rejeita CPF inválido com mensagem específica.Aprove. Roda:
Roda o teste do Zod schema. Confirma que CPF inválido é rejeitado com mensagem "CPF inválido".Passo 3 — Conecte o schema à API route
Cole no Claude:
Crie (ou atualize) a API route `POST /api/s` (plural), com este shape:
1. Importa `createSchema` do schema do Passo 2.
2. Lê o body do request.
3. Valida com `createSchema.safeParse(body)` — sem throw.
4. Se `!success`: retorna 400 com `{ error: 'invalid_payload', issues: error.issues }`.
5. Se `success`: por enquanto, retorna 200 com o payload validado (insert no banco vem na L04).
6. Sem try/catch defensivo no caminho feliz — Zod já filtrou.
Mostre o diff. Inclua 1 teste E2E: POST com CPF inválido retorna 400; CPF válido retorna 200.Aprove. Roda:
Roda o teste da API route. Mostra o output dos dois cenários (CPF inválido = 400, CPF válido = 200).Quando os dois passam, a borda está blindada. Qualquer request daqui pra frente é garantidamente válido, e o resto do código pode confiar.
Passo 4 — Faça o commit
Faça commit dos três arquivos criados (validador, Zod schema, API route + testes). Stage os arquivos, com mensagem `feat(validation): zod schema for [recurso] + cpf validator`. Mostre os comandos antes de executar.Aprove git add e git commit. Confira no GitHub.
Takeaways
- Valide na borda, onde dado externo entra. Cada borda fica blindada; o resto do código pode confiar.
- Use
.refine()do Zod pra encaixar validação custom (CPF/CNPJ) dentro do schema. Erro vem com path do campo + mensagem em pt-BR. - (sem throw) é o default em API route, evita try/catch defensivo.
parselança exceção; só use quando você quer que o erro suba. - Validação assíncrona (consulta o banco) é exceção, não default. Pra duplicata, use constraint do banco + 409 na L04, não
.refineasync.
Você terminou quando
Quatro coisas:
- Função
validateCpf(ouvalidateCnpj) com testes verdes - Zod schema do recurso com
.refineusando a função do item 1 - API route validando o payload com 400 pra inválido / 200 pra válido (insert vem na L04)
- Tudo commitado no GitHub