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:
@@ -97,6 +97,7 @@ export type {
|
||||
ListClientRegistrationsFilter,
|
||||
CreateClientRegistrationInput,
|
||||
UpdateClientRegistrationProfileInput,
|
||||
ApplyCnpjLookupInput,
|
||||
} from "./repositories/client-registrations.js";
|
||||
export type {
|
||||
ClientRegistrationPartner,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { query } from "../pool.js";
|
||||
|
||||
/**
|
||||
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). In the
|
||||
* legacy this table is populated from a public CNPJ lookup (cnpj.ws/
|
||||
* BrasilAPI); that integration is out of scope for this MVP (see the
|
||||
* migration comment — `source` defaults to `'manual'` here instead of the
|
||||
* legacy's `'brasilapi'`, since nothing here comes from an external
|
||||
* lookup). Only the backoffice-correction slice of the legacy flow
|
||||
* (eden.md §2.5: correcting cpf/will_sign/signature_email after
|
||||
* submission) is implemented — partners must be entered manually in this
|
||||
* round.
|
||||
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). `source`
|
||||
* defaults to `'manual'` (DB default) for hand-entered partners; rows
|
||||
* created by the CNPJ-lookup flow (`ClientRegistrationsService.
|
||||
* applyCnpjLookup`, `POST /v1/client-registrations/:id/cnpj-lookup`) carry
|
||||
* `source = 'cnpj_lookup'` instead of the legacy's hardcoded `'brasilapi'`
|
||||
* — deliberately more accurate here, since the primary source in this
|
||||
* port is cnpj.ws, not BrasilAPI (see `packages/integrations/src/
|
||||
* cnpj-lookup/lookup.ts`). The backoffice-correction slice of the legacy
|
||||
* flow (eden.md §2.5: correcting cpf/will_sign/signature_email after
|
||||
* submission) is also implemented below.
|
||||
*/
|
||||
export interface ClientRegistrationPartner {
|
||||
id: string;
|
||||
@@ -50,19 +51,28 @@ export interface CreatePartnerInput {
|
||||
cpf?: string | null;
|
||||
willSign?: boolean;
|
||||
signatureEmail?: string | null;
|
||||
/**
|
||||
* Server-only — never accepted from `CreatePartnerDto` (client-settable
|
||||
* `source` would defeat the point of attribution). Defaults to
|
||||
* `'manual'` (DB default) when omitted; the CNPJ-lookup flow
|
||||
* (`ClientRegistrationsService.applyCnpjLookup`) is the only caller that
|
||||
* passes a non-default value.
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export async function createPartner(input: CreatePartnerInput): Promise<ClientRegistrationPartner> {
|
||||
const { rows } = await query<ClientRegistrationPartner>(
|
||||
`INSERT INTO client_registration_partners
|
||||
(client_registration_id, name, document, qualification, cpf, will_sign, signature_email)
|
||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7)
|
||||
(client_registration_id, name, document, qualification, source, cpf, will_sign, signature_email)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5, 'manual'), $6, COALESCE($7, false), $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.clientRegistrationId,
|
||||
input.name,
|
||||
input.document ?? null,
|
||||
input.qualification ?? null,
|
||||
input.source ?? null,
|
||||
input.cpf ?? null,
|
||||
input.willSign ?? null,
|
||||
input.signatureEmail ?? null,
|
||||
@@ -73,6 +83,18 @@ export async function createPartner(input: CreatePartnerInput): Promise<ClientRe
|
||||
return partner;
|
||||
}
|
||||
|
||||
/** Dedup key for the CNPJ-lookup flow: don't recreate a partner already recorded for this registration+document. */
|
||||
export async function findPartnerByDocument(
|
||||
clientRegistrationId: string,
|
||||
document: string,
|
||||
): Promise<ClientRegistrationPartner | null> {
|
||||
const { rows } = await query<ClientRegistrationPartner>(
|
||||
"SELECT * FROM client_registration_partners WHERE client_registration_id = $1 AND document = $2 LIMIT 1",
|
||||
[clientRegistrationId, document],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** eden.md §2.5: backoffice can correct only cpf/will_sign/signature_email post-submission. */
|
||||
export interface UpdatePartnerInput {
|
||||
cpf?: string | null;
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface PortabilityRange {
|
||||
* MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): deliberately
|
||||
* out of scope for this round — the public unauthenticated token-based
|
||||
* self-service form (`GET/POST /public-client-registration/:token`), file
|
||||
* attachment upload/storage, CNPJ/CEP external lookup integration, the
|
||||
* attachment upload/storage, CEP-only external lookup integration, the
|
||||
* "official" `cnpj_address_*` Receita Federal address block, and the
|
||||
* `*_externally_verified`/`*_verification_*` columns (only the format-check
|
||||
* booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those all land
|
||||
@@ -339,3 +339,82 @@ export async function updateClientRegistrationProfile(
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated flow for `pj_cnpj` — the one function in this repository
|
||||
* allowed to set it. `updateClientRegistrationProfile` above deliberately
|
||||
* cannot touch `pj_cnpj` (its Input type structurally excludes it, per
|
||||
* eden.md §1.1.2); this is the controlled re-verification path that
|
||||
* comment anticipated: the server always re-resolves the CNPJ against the
|
||||
* authoritative lookup APIs before calling this (see
|
||||
* `ClientRegistrationsService.applyCnpjLookup`) — the caller never trusts
|
||||
* a client-submitted "already looked up" payload for persistence.
|
||||
*
|
||||
* Every non-CNPJ field is `COALESCE`d against the existing column, same
|
||||
* as `updateClientRegistrationProfile`: a field the lookup didn't bring
|
||||
* back (mapped to `null` by the caller) never clobbers a value already on
|
||||
* file — including `pj_state_registration` and the `address_*` block,
|
||||
* which the API may legitimately omit.
|
||||
*/
|
||||
export interface ApplyCnpjLookupInput {
|
||||
cnpj: string;
|
||||
companyName: string | null;
|
||||
tradeName: string | null;
|
||||
stateRegistration: string | null;
|
||||
openingDate: string | null;
|
||||
legalNature: string | null;
|
||||
cnpjSituation: string | null;
|
||||
mainActivity: string | null;
|
||||
addressZip: string | null;
|
||||
addressStreet: string | null;
|
||||
addressNumber: string | null;
|
||||
addressComplement: string | null;
|
||||
addressNeighborhood: string | null;
|
||||
addressCity: string | null;
|
||||
addressState: string | null;
|
||||
}
|
||||
|
||||
export async function applyCnpjLookup(
|
||||
id: string,
|
||||
input: ApplyCnpjLookupInput,
|
||||
): Promise<ClientRegistration | null> {
|
||||
const { rows } = await query<ClientRegistration>(
|
||||
`UPDATE client_registrations SET
|
||||
pj_cnpj = $2,
|
||||
pj_company_name = COALESCE($3, pj_company_name),
|
||||
pj_trade_name = COALESCE($4, pj_trade_name),
|
||||
pj_state_registration = COALESCE($5, pj_state_registration),
|
||||
pj_opening_date = COALESCE($6::date, pj_opening_date),
|
||||
pj_legal_nature = COALESCE($7, pj_legal_nature),
|
||||
pj_cnpj_situation = COALESCE($8, pj_cnpj_situation),
|
||||
pj_main_activity = COALESCE($9, pj_main_activity),
|
||||
address_zip = COALESCE($10, address_zip),
|
||||
address_street = COALESCE($11, address_street),
|
||||
address_number = COALESCE($12, address_number),
|
||||
address_complement = COALESCE($13, address_complement),
|
||||
address_neighborhood = COALESCE($14, address_neighborhood),
|
||||
address_city = COALESCE($15, address_city),
|
||||
address_state = COALESCE($16, address_state)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[
|
||||
id,
|
||||
input.cnpj,
|
||||
input.companyName,
|
||||
input.tradeName,
|
||||
input.stateRegistration,
|
||||
input.openingDate,
|
||||
input.legalNature,
|
||||
input.cnpjSituation,
|
||||
input.mainActivity,
|
||||
input.addressZip,
|
||||
input.addressStreet,
|
||||
input.addressNumber,
|
||||
input.addressComplement,
|
||||
input.addressNeighborhood,
|
||||
input.addressCity,
|
||||
input.addressState,
|
||||
],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
214
packages/integrations/src/cnpj-lookup/lookup.ts
Normal file
214
packages/integrations/src/cnpj-lookup/lookup.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user