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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user