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()
|
||||
|
||||
183
apps/api/test/client-registrations-cnpj-lookup.spec.ts
Normal file
183
apps/api/test/client-registrations-cnpj-lookup.spec.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
|
||||
import type { CnpjLookupResult } from "@eden/integrations";
|
||||
|
||||
/**
|
||||
* POST /v1/client-registrations/:id/cnpj-lookup — the whole test suite
|
||||
* mocks `lookupCnpj` (network calls to cnpj.ws/BrasilAPI are NOT allowed
|
||||
* in this suite; a real call against the internet is done manually, once,
|
||||
* outside of vitest — see the task notes).
|
||||
*/
|
||||
const lookupCnpjMock = vi.fn<(cnpj: string) => Promise<CnpjLookupResult | null>>();
|
||||
|
||||
vi.mock("@eden/integrations", async () => {
|
||||
const actual = await vi.importActual<typeof import("@eden/integrations")>("@eden/integrations");
|
||||
return {
|
||||
...actual,
|
||||
lookupCnpj: (cnpj: string) => lookupCnpjMock(cnpj),
|
||||
};
|
||||
});
|
||||
|
||||
const FOUND_RESULT: CnpjLookupResult = {
|
||||
companyName: "qa-test- Empresa Lookup LTDA",
|
||||
tradeName: "qa-test- Lookup",
|
||||
openingDate: "2010-05-04",
|
||||
legalNature: "Sociedade Empresária Limitada",
|
||||
cnpjSituation: "ATIVA",
|
||||
mainActivity: "Desenvolvimento de programas de computador sob encomenda",
|
||||
address: {
|
||||
zip: "01310-100",
|
||||
street: "Av. Paulista",
|
||||
number: "1000",
|
||||
complement: "Sala 10",
|
||||
neighborhood: "Bela Vista",
|
||||
city: "São Paulo",
|
||||
state: "SP",
|
||||
},
|
||||
stateRegistration: "123.456.789.110",
|
||||
partners: [
|
||||
{ name: "Fulano de Tal", document: "***123456**", qualification: "Sócio-Administrador" },
|
||||
{ name: "Beltrano da Silva", document: "***654321**", qualification: "Sócio" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("client-registrations — CNPJ lookup autofill", () => {
|
||||
let app: INestApplication;
|
||||
let admin: AuthedTokens;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
admin = await loginSuperAdmin(app);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
lookupCnpjMock.mockReset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeTestApp(app);
|
||||
});
|
||||
|
||||
async function createDirectRegistration(personType: "pf" | "pj") {
|
||||
const res = await agent(app)
|
||||
.post("/api/v1/client-registrations/direct")
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ personType, isPortability: false })
|
||||
.expect(201);
|
||||
return res.body.id as string;
|
||||
}
|
||||
|
||||
it("applies company data and creates partners when the CNPJ is found", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(FOUND_RESULT);
|
||||
const id = await createDirectRegistration("pj");
|
||||
|
||||
const res = await agent(app)
|
||||
.post(`/api/v1/client-registrations/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11.222.333/0001-81" })
|
||||
.expect(201);
|
||||
|
||||
expect(lookupCnpjMock).toHaveBeenCalledWith("11.222.333/0001-81");
|
||||
expect(res.body.registration.pj_cnpj).toBe("11222333000181");
|
||||
expect(res.body.registration.pj_company_name).toBe(FOUND_RESULT.companyName);
|
||||
expect(res.body.registration.pj_trade_name).toBe(FOUND_RESULT.tradeName);
|
||||
expect(res.body.registration.pj_state_registration).toBe(FOUND_RESULT.stateRegistration);
|
||||
expect(res.body.registration.pj_legal_nature).toBe(FOUND_RESULT.legalNature);
|
||||
expect(res.body.registration.pj_cnpj_situation).toBe(FOUND_RESULT.cnpjSituation);
|
||||
expect(res.body.registration.pj_main_activity).toBe(FOUND_RESULT.mainActivity);
|
||||
expect(res.body.registration.address_zip).toBe(FOUND_RESULT.address.zip);
|
||||
expect(res.body.registration.address_street).toBe(FOUND_RESULT.address.street);
|
||||
expect(res.body.registration.address_city).toBe(FOUND_RESULT.address.city);
|
||||
expect(res.body.registration.address_state).toBe(FOUND_RESULT.address.state);
|
||||
expect(res.body.partners).toHaveLength(2);
|
||||
expect(res.body.partners.map((p: { name: string }) => p.name).sort()).toEqual(
|
||||
["Beltrano da Silva", "Fulano de Tal"].sort(),
|
||||
);
|
||||
expect(res.body.partners.every((p: { source: string }) => p.source === "cnpj_lookup")).toBe(true);
|
||||
expect(res.body.partners.every((p: { will_sign: boolean }) => p.will_sign === false)).toBe(true);
|
||||
|
||||
const getRes = await agent(app)
|
||||
.get(`/api/v1/client-registrations/${id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(getRes.body.pj_cnpj).toBe("11222333000181");
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/client-registrations/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("running the lookup twice does not duplicate partners (dedup by document)", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(FOUND_RESULT);
|
||||
const id = await createDirectRegistration("pj");
|
||||
|
||||
await agent(app)
|
||||
.post(`/api/v1/client-registrations/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(201);
|
||||
|
||||
const second = await agent(app)
|
||||
.post(`/api/v1/client-registrations/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(201);
|
||||
|
||||
expect(second.body.partners).toHaveLength(2);
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/client-registrations/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns 404 and applies no changes when the CNPJ is not found in either source", async () => {
|
||||
lookupCnpjMock.mockResolvedValue(null);
|
||||
const id = await createDirectRegistration("pj");
|
||||
|
||||
await agent(app)
|
||||
.post(`/api/v1/client-registrations/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "99999999000199" })
|
||||
.expect(404);
|
||||
|
||||
const getRes = await agent(app)
|
||||
.get(`/api/v1/client-registrations/${id}`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(getRes.body.pj_cnpj).toBeNull();
|
||||
expect(getRes.body.pj_company_name).toBeNull();
|
||||
|
||||
const partnersRes = await agent(app)
|
||||
.get(`/api/v1/client-registrations/${id}/partners`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.expect(200);
|
||||
expect(partnersRes.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects CNPJ lookup for a PF registration with 400", async () => {
|
||||
const id = await createDirectRegistration("pf");
|
||||
|
||||
const res = await agent(app)
|
||||
.post(`/api/v1/client-registrations/${id}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(400);
|
||||
expect(res.body.message).toMatch(/pessoa jurídica/i);
|
||||
expect(lookupCnpjMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 404 for a client registration id that doesn't exist", async () => {
|
||||
await agent(app)
|
||||
.post(`/api/v1/client-registrations/${randomUUID()}/cnpj-lookup`)
|
||||
.set(...bearer(admin.accessToken))
|
||||
.send({ cnpj: "11222333000181" })
|
||||
.expect(404);
|
||||
expect(lookupCnpjMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user