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

@@ -5,6 +5,7 @@ import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-re
import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
import { UpdatePartnerDto } from "./dto/update-partner.dto.js";
import { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
@@ -18,8 +19,9 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
* the `client-registrations` resource permission, since this data carries
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
* for this round: the public unauthenticated token-based self-service
* form, file attachments, CNPJ/CEP lookup — see the migration and
* repository comments for the full list.
* form, file attachments, CEP-only lookup — see the migration and
* repository comments for the full list. CNPJ lookup (`POST
* :id/cnpj-lookup`) IS implemented — see `client-registrations.service.ts`.
*/
@ApiTags("client-registrations")
@ApiBearerAuth()
@@ -85,4 +87,14 @@ export class ClientRegistrationsController {
) {
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
}
@Post(":id/cnpj-lookup")
@RequirePermission("client-registrations", "edit")
applyCnpjLookup(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ApplyCnpjLookupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.clientRegistrations.applyCnpjLookup(id, dto, user);
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
clientRegistrationsRepo,
@@ -6,12 +6,29 @@ import {
type ClientRegistration,
type ClientRegistrationPartner,
} from "@eden/database";
import { lookupCnpj } from "@eden/integrations";
import { maxGrantedScope } from "../auth/permissions.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
import type { CreatePartnerDto } from "./dto/create-partner.dto.js";
import type { UpdatePartnerDto } from "./dto/update-partner.dto.js";
import type { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js";
/**
* Source tag for partners created by the CNPJ-lookup flow below —
* deliberately not the legacy's hardcoded `'brasilapi'` (see
* `client-registration-partners.ts`'s module comment): cnpj.ws is the
* primary source in this port, so a fixed `'brasilapi'` tag would
* misattribute provenance on the (common) success path.
*/
const CNPJ_LOOKUP_PARTNER_SOURCE = "cnpj_lookup";
/** `undefined`/blank → `null`, so a field the lookup didn't bring back never clobbers an existing value (COALESCE in the repo). */
function orNull(value: string | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
type ResolvedScope =
| { kind: "all" }
@@ -234,4 +251,78 @@ export class ClientRegistrationsService {
return partner;
}
/**
* `POST /:id/cnpj-lookup` — single dedicated action that resolves and
* applies CNPJ data in one step (no separate "preview" endpoint; mirrors
* the legacy's on-blur autofill). The server always re-runs the
* authoritative lookup itself from `dto.cnpj` — never trusts a
* client-submitted "already found" payload for persistence (Master
* Prompt §23 / this file's class-level discipline).
*/
async applyCnpjLookup(
id: string,
dto: ApplyCnpjLookupDto,
user: EdenAuthenticatedUser,
): Promise<{ registration: ClientRegistration; partners: ClientRegistrationPartner[] }> {
const registration = await this.getForEdit(id, user);
if (registration.person_type !== "pj") {
throw new BadRequestException("Lookup de CNPJ só se aplica a cadastros de pessoa jurídica (PJ)");
}
const result = await lookupCnpj(dto.cnpj);
if (!result) {
throw new NotFoundException("CNPJ não encontrado — preencha os dados manualmente.");
}
const normalizedCnpj = dto.cnpj.replace(/\D/g, "");
const updated = await clientRegistrationsRepo.applyCnpjLookup(id, {
cnpj: normalizedCnpj,
companyName: orNull(result.companyName),
tradeName: orNull(result.tradeName),
stateRegistration: orNull(result.stateRegistration),
openingDate: orNull(result.openingDate),
legalNature: orNull(result.legalNature),
cnpjSituation: orNull(result.cnpjSituation),
mainActivity: orNull(result.mainActivity),
addressZip: orNull(result.address.zip),
addressStreet: orNull(result.address.street),
addressNumber: orNull(result.address.number),
addressComplement: orNull(result.address.complement),
addressNeighborhood: orNull(result.address.neighborhood),
addressCity: orNull(result.address.city),
addressState: orNull(result.address.state),
});
if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado");
let partnersCreated = 0;
for (const partner of result.partners) {
const document = orNull(partner.document);
if (document) {
const existing = await clientRegistrationPartnersRepo.findPartnerByDocument(id, document);
if (existing) continue;
}
await clientRegistrationPartnersRepo.createPartner({
clientRegistrationId: id,
name: partner.name,
document,
qualification: orNull(partner.qualification),
source: CNPJ_LOOKUP_PARTNER_SOURCE,
});
partnersCreated += 1;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "client_registration.cnpj_lookup_applied",
resourceType: "client_registration",
resourceId: id,
metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated },
});
const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(id);
return { registration: updated, partners };
}
}

View File

@@ -0,0 +1,15 @@
import { IsString, MinLength } from "class-validator";
/**
* Body for `POST /:id/cnpj-lookup`. The server re-resolves this CNPJ
* itself against the authoritative lookup APIs (`@eden/integrations`'s
* `lookupCnpj`) — this DTO carries only the raw input, never any
* "already looked up" data from the client. Full 14-digit/malformed
* validation happens inside `lookupCnpj` itself (returns `null`, mapped
* to 404 by the service); this is just a basic shape guard.
*/
export class ApplyCnpjLookupDto {
@IsString()
@MinLength(11)
cnpj!: string;
}

View File

@@ -3,8 +3,11 @@ import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-valid
/**
* `source` is not client-settable — always defaults to `'manual'`
* (DB default, packages/database/migrations/*_client-registrations.cjs)
* since this MVP has no CNPJ-lookup integration to attribute it to
* (eden.md §1.2 §4 documents the legacy default as `'brasilapi'`).
* for partners entered through this endpoint. Partners populated by the
* CNPJ-lookup flow (`POST :id/cnpj-lookup`) go through a different,
* server-only path (`clientRegistrationPartnersRepo.createPartner`'s
* `source` param) and are tagged `'cnpj_lookup'` instead — see
* `client-registrations.service.ts`.
*/
export class CreatePartnerDto {
@IsString()