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

@@ -97,6 +97,7 @@ export type {
ListClientRegistrationsFilter,
CreateClientRegistrationInput,
UpdateClientRegistrationProfileInput,
ApplyCnpjLookupInput,
} from "./repositories/client-registrations.js";
export type {
ClientRegistrationPartner,

View File

@@ -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;

View File

@@ -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;
}