feat(client-registrations): busca automática de CNPJ (dados, sócios, IE)

Ao digitar/já ter um CNPJ num cadastro de cliente PJ, busca automática
contra a Receita Federal (cnpj.ws primário, BrasilAPI fallback) e
preenche razão social, endereço e inscrição estadual, além de criar os
sócios encontrados (sem duplicar em buscas repetidas) — pra usar depois
na confecção do contrato/assinatura.

Fecha uma lacuna real que já estava prevista mas nunca implementada:
pj_cnpj não tinha nenhuma forma de ser gravado (UpdateClientRegistrationDto
o excluía deliberadamente, esperando por "a dedicated re-verification
flow" — exatamente o que este endpoint é). O servidor sempre refaz a
própria consulta autoritativa a partir do CNPJ recebido, nunca confia
em dado "já encontrado" vindo do cliente.

Suíte: 100/100 (95→100). pnpm turbo run typecheck build: 28/28.
Testado ao vivo contra CNPJs reais (Petrobras, Banco do Brasil) e em
navegador real via Playwright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:12:15 -03:00
parent 53c3031775
commit 93cd42b22e
14 changed files with 783 additions and 20 deletions

View File

@@ -0,0 +1,214 @@
/**
* CNPJ lookup adapter — port of the legacy CNPJ autofill (documented in
* `/home/handix/cadastro.md`, not part of this repo). Two free, no-API-key
* public aggregators of Receita Federal data:
*
* 1. cnpj.ws (`publica.cnpj.ws`) — primary; the ONLY one of the two that
* returns inscrição estadual.
* 2. BrasilAPI (`brasilapi.com.br`) — fallback, only tried when the
* primary fails with a network error or 5xx. An explicit 404 from the
* primary means "this CNPJ doesn't exist" and is treated as a final
* empty result — it does NOT fall through to the fallback.
*/
export interface CnpjLookupAddress {
zip: string;
street: string;
number: string;
complement: string;
neighborhood: string;
city: string;
state: string;
}
export interface CnpjLookupPartner {
name: string;
document: string;
qualification: string;
}
export interface CnpjLookupResult {
companyName: string;
tradeName: string;
openingDate: string;
legalNature: string;
cnpjSituation: string;
mainActivity: string;
address: CnpjLookupAddress;
/** Empty string when the source has no active/known inscrição estadual (BrasilAPI never has one). */
stateRegistration: string;
partners: CnpjLookupPartner[];
}
const CNPJ_WS_BASE = "https://publica.cnpj.ws/cnpj";
const BRASIL_API_BASE = "https://brasilapi.com.br/api/cnpj/v1";
const REQUEST_TIMEOUT_MS = 10_000;
interface CnpjWsInscricaoEstadual {
inscricao_estadual?: string;
ativo?: boolean;
}
interface CnpjWsResponse {
razao_social?: string;
natureza_juridica?: { descricao?: string };
estabelecimento?: {
nome_fantasia?: string;
data_inicio_atividade?: string;
situacao_cadastral?: string;
atividade_principal?: { descricao?: string };
cep?: string;
logradouro?: string;
numero?: string;
complemento?: string;
bairro?: string;
cidade?: { nome?: string };
estado?: { sigla?: string };
inscricoes_estaduais?: CnpjWsInscricaoEstadual[];
};
socios?: Array<{
nome?: string;
cpf_cnpj_socio?: string;
qualificacao_socio?: { descricao?: string };
}>;
}
interface BrasilApiResponse {
razao_social?: string;
nome_fantasia?: string;
data_inicio_atividade?: string;
natureza_juridica?: string;
descricao_situacao_cadastral?: string;
cnae_fiscal_descricao?: string;
cep?: string;
logradouro?: string;
numero?: string;
complemento?: string;
bairro?: string;
municipio?: string;
uf?: string;
qsa?: Array<{
nome_socio?: string;
cnpj_cpf_do_socio?: string;
qualificacao_socio?: string;
}>;
}
/** `status: 404` is surfaced (not thrown) so the caller can tell "not found" apart from a network/5xx failure. */
async function fetchJson(url: string): Promise<{ status: number; body: unknown }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal, headers: { accept: "application/json" } });
if (res.status === 404) {
return { status: 404, body: null };
}
if (!res.ok) {
throw new Error(`cnpj lookup: ${url} responded ${res.status}`);
}
return { status: res.status, body: await res.json() };
} finally {
clearTimeout(timeout);
}
}
function mapCnpjWs(raw: unknown): CnpjLookupResult {
const data = raw as CnpjWsResponse;
const estab = data.estabelecimento ?? {};
const inscricoes = estab.inscricoes_estaduais ?? [];
const activeIe = inscricoes.find((ie) => ie.ativo === true) ?? inscricoes[0];
return {
companyName: data.razao_social ?? "",
tradeName: estab.nome_fantasia ?? "",
openingDate: estab.data_inicio_atividade ?? "",
legalNature: data.natureza_juridica?.descricao ?? "",
cnpjSituation: estab.situacao_cadastral ?? "",
mainActivity: estab.atividade_principal?.descricao ?? "",
address: {
zip: estab.cep ?? "",
street: estab.logradouro ?? "",
number: estab.numero ?? "",
complement: estab.complemento ?? "",
neighborhood: estab.bairro ?? "",
city: estab.cidade?.nome ?? "",
state: estab.estado?.sigla ?? "",
},
stateRegistration: activeIe?.inscricao_estadual ?? "",
partners: (data.socios ?? []).map((s) => ({
name: s.nome ?? "",
// Already masked by the API itself (e.g. "***123456**") — never
// treated as, or validated like, a full CPF.
document: s.cpf_cnpj_socio ?? "",
qualification: s.qualificacao_socio?.descricao?.trim() ?? "",
})),
};
}
function mapBrasilApi(raw: unknown): CnpjLookupResult {
const data = raw as BrasilApiResponse;
return {
companyName: data.razao_social ?? "",
tradeName: data.nome_fantasia ?? "",
openingDate: data.data_inicio_atividade ?? "",
legalNature: data.natureza_juridica ?? "",
cnpjSituation: data.descricao_situacao_cadastral ?? "",
mainActivity: data.cnae_fiscal_descricao ?? "",
address: {
zip: data.cep ?? "",
street: data.logradouro ?? "",
number: data.numero ?? "",
complement: data.complemento ?? "",
neighborhood: data.bairro ?? "",
city: data.municipio ?? "",
state: data.uf ?? "",
},
// BrasilAPI's CNPJ endpoint never carries inscrição estadual — cnpj.ws
// (the primary) is the only source for that field.
stateRegistration: "",
partners: (data.qsa ?? []).map((s) => ({
name: s.nome_socio ?? "",
document: s.cnpj_cpf_do_socio ?? "",
qualification: s.qualificacao_socio?.trim() ?? "",
})),
};
}
/**
* Returns `null` when the CNPJ is malformed (not exactly 14 digits after
* stripping non-digits) or not found in either source. Never throws for a
* "not found" outcome — only for a genuine unexpected failure of both
* sources.
*/
export async function lookupCnpj(cnpj: string): Promise<CnpjLookupResult | null> {
const digits = cnpj.replace(/\D/g, "");
if (digits.length !== 14) {
return null;
}
let primaryNotFound = false;
try {
const primary = await fetchJson(`${CNPJ_WS_BASE}/${digits}`);
if (primary.status === 404) {
primaryNotFound = true;
} else {
return mapCnpjWs(primary.body);
}
} catch {
// Network error or 5xx on the primary — fall through to BrasilAPI.
}
if (primaryNotFound) {
return null;
}
try {
const fallback = await fetchJson(`${BRASIL_API_BASE}/${digits}`);
if (fallback.status === 404) {
return null;
}
return mapBrasilApi(fallback.body);
} catch {
return null;
}
}

View File

@@ -8,3 +8,4 @@ export * from "./s3/client.js";
export * from "./smtp/mailer.js";
export * from "./pdf/renderer.js";
export * from "./pdf/merge.js";
export * from "./cnpj-lookup/lookup.js";