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 { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js";
|
||||||
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
import { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||||
import { UpdatePartnerDto } from "./dto/update-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 { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.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
|
* the `client-registrations` resource permission, since this data carries
|
||||||
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
|
* customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope
|
||||||
* for this round: the public unauthenticated token-based self-service
|
* for this round: the public unauthenticated token-based self-service
|
||||||
* form, file attachments, CNPJ/CEP lookup — see the migration and
|
* form, file attachments, CEP-only lookup — see the migration and
|
||||||
* repository comments for the full list.
|
* repository comments for the full list. CNPJ lookup (`POST
|
||||||
|
* :id/cnpj-lookup`) IS implemented — see `client-registrations.service.ts`.
|
||||||
*/
|
*/
|
||||||
@ApiTags("client-registrations")
|
@ApiTags("client-registrations")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -85,4 +87,14 @@ export class ClientRegistrationsController {
|
|||||||
) {
|
) {
|
||||||
return this.clientRegistrations.updatePartner(id, partnerId, dto, user);
|
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 {
|
import {
|
||||||
auditRepo,
|
auditRepo,
|
||||||
clientRegistrationsRepo,
|
clientRegistrationsRepo,
|
||||||
@@ -6,12 +6,29 @@ import {
|
|||||||
type ClientRegistration,
|
type ClientRegistration,
|
||||||
type ClientRegistrationPartner,
|
type ClientRegistrationPartner,
|
||||||
} from "@eden/database";
|
} from "@eden/database";
|
||||||
|
import { lookupCnpj } from "@eden/integrations";
|
||||||
import { maxGrantedScope } from "../auth/permissions.js";
|
import { maxGrantedScope } from "../auth/permissions.js";
|
||||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||||
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js";
|
||||||
import type { UpdateClientRegistrationDto } from "./dto/update-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 { CreatePartnerDto } from "./dto/create-partner.dto.js";
|
||||||
import type { UpdatePartnerDto } from "./dto/update-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 =
|
type ResolvedScope =
|
||||||
| { kind: "all" }
|
| { kind: "all" }
|
||||||
@@ -234,4 +251,78 @@ export class ClientRegistrationsService {
|
|||||||
|
|
||||||
return partner;
|
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'`
|
* `source` is not client-settable — always defaults to `'manual'`
|
||||||
* (DB default, packages/database/migrations/*_client-registrations.cjs)
|
* (DB default, packages/database/migrations/*_client-registrations.cjs)
|
||||||
* since this MVP has no CNPJ-lookup integration to attribute it to
|
* for partners entered through this endpoint. Partners populated by the
|
||||||
* (eden.md §1.2 §4 documents the legacy default as `'brasilapi'`).
|
* 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 {
|
export class CreatePartnerDto {
|
||||||
@IsString()
|
@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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -264,3 +264,28 @@ export function updateClientRegistrationPartner(
|
|||||||
{ accessToken },
|
{ accessToken },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of `POST /v1/client-registrations/:id/cnpj-lookup` on a match (201). */
|
||||||
|
export interface ClientRegistrationCnpjLookupResult {
|
||||||
|
registration: ClientRegistration;
|
||||||
|
partners: ClientRegistrationPartner[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a CNPJ against the external source and applies the result to the
|
||||||
|
* registration (company data, address, partners) server-side. 404 means the
|
||||||
|
* CNPJ wasn't found — caller should surface the API's message and leave the
|
||||||
|
* registration untouched (no exception-free "not found" sentinel here, the
|
||||||
|
* caller catches `ApiError` and inspects `.status`/`.message` as elsewhere).
|
||||||
|
*/
|
||||||
|
export function applyClientRegistrationCnpjLookup(
|
||||||
|
id: string,
|
||||||
|
cnpj: string,
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<ClientRegistrationCnpjLookupResult> {
|
||||||
|
return apiRequest<ClientRegistrationCnpjLookupResult>(
|
||||||
|
`/client-registrations/${id}/cnpj-lookup`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ cnpj }) },
|
||||||
|
{ accessToken },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { BadgeVariant } from "@eden/ui";
|
|||||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
import { ApiError } from "../../lib/api/http.js";
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
import {
|
import {
|
||||||
|
applyClientRegistrationCnpjLookup,
|
||||||
getClientRegistration,
|
getClientRegistration,
|
||||||
updateClientRegistration,
|
updateClientRegistration,
|
||||||
REGISTRATION_STATUSES,
|
REGISTRATION_STATUSES,
|
||||||
@@ -174,6 +175,13 @@ export function ClientRegistrationDetailPage() {
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [cnpjInput, setCnpjInput] = useState("");
|
||||||
|
const [cnpjLookupLoading, setCnpjLookupLoading] = useState(false);
|
||||||
|
const [cnpjLookupError, setCnpjLookupError] = useState<string | null>(null);
|
||||||
|
const [cnpjLookupNotFound, setCnpjLookupNotFound] = useState<string | null>(null);
|
||||||
|
const [cnpjLookupSuccess, setCnpjLookupSuccess] = useState<string | null>(null);
|
||||||
|
const [partnersRefreshKey, setPartnersRefreshKey] = useState(0);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!id || !accessToken) return;
|
if (!id || !accessToken) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -273,6 +281,31 @@ export function ClientRegistrationDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runCnpjLookup(cnpj: string) {
|
||||||
|
if (!accessToken || !id) return;
|
||||||
|
setCnpjLookupError(null);
|
||||||
|
setCnpjLookupNotFound(null);
|
||||||
|
setCnpjLookupSuccess(null);
|
||||||
|
setCnpjLookupLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await applyClientRegistrationCnpjLookup(id, cnpj, accessToken);
|
||||||
|
setRegistration(result.registration);
|
||||||
|
setForm(toFormState(result.registration));
|
||||||
|
setCnpjLookupSuccess(
|
||||||
|
`Dados encontrados e preenchidos automaticamente. ${result.partners.length} sócio(s) no cadastro.`,
|
||||||
|
);
|
||||||
|
setPartnersRefreshKey((k) => k + 1);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
setCnpjLookupNotFound(err.message);
|
||||||
|
} else {
|
||||||
|
setCnpjLookupError(err instanceof ApiError ? err.message : "Não foi possível buscar o CNPJ.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setCnpjLookupLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -505,8 +538,59 @@ export function ClientRegistrationDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card title="Dados PJ">
|
<Card title="Dados PJ">
|
||||||
|
{cnpjLookupError ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{cnpjLookupError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{cnpjLookupNotFound ? (
|
||||||
|
<Alert variant="warning" className="mb-4">
|
||||||
|
{cnpjLookupNotFound}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{cnpjLookupSuccess ? (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
{cnpjLookupSuccess}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Input label="CNPJ" value={formatDocument(registration.pj_cnpj)} readOnly disabled />
|
{registration.pj_cnpj ? (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input label="CNPJ" value={formatDocument(registration.pj_cnpj)} readOnly disabled />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={cnpjLookupLoading}
|
||||||
|
onClick={() => runCnpjLookup(registration.pj_cnpj as string)}
|
||||||
|
>
|
||||||
|
Buscar novamente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label="CNPJ"
|
||||||
|
value={cnpjInput}
|
||||||
|
onChange={(e) => setCnpjInput(e.target.value)}
|
||||||
|
placeholder="00.000.000/0000-00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={cnpjLookupLoading}
|
||||||
|
disabled={cnpjInput.replace(/\D/g, "").length < 14}
|
||||||
|
onClick={() => runCnpjLookup(cnpjInput)}
|
||||||
|
>
|
||||||
|
Buscar CNPJ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label="Razão social"
|
label="Razão social"
|
||||||
value={form.pjCompanyName}
|
value={form.pjCompanyName}
|
||||||
@@ -605,7 +689,11 @@ export function ClientRegistrationDetailPage() {
|
|||||||
|
|
||||||
{registration.person_type === "pj" ? (
|
{registration.person_type === "pj" ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<PartnersSection registrationId={registration.id} accessToken={accessToken} />
|
<PartnersSection
|
||||||
|
registrationId={registration.id}
|
||||||
|
accessToken={accessToken}
|
||||||
|
refreshKey={partnersRefreshKey}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ interface EditState {
|
|||||||
export function PartnersSection({
|
export function PartnersSection({
|
||||||
registrationId,
|
registrationId,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
refreshKey,
|
||||||
}: {
|
}: {
|
||||||
registrationId: string;
|
registrationId: string;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
|
/** Bump (e.g. a counter) to force a reload — used after a successful CNPJ lookup adds/updates partners. */
|
||||||
|
refreshKey?: unknown;
|
||||||
}) {
|
}) {
|
||||||
const [partners, setPartners] = useState<ClientRegistrationPartner[] | null>(null);
|
const [partners, setPartners] = useState<ClientRegistrationPartner[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -63,7 +66,7 @@ export function PartnersSection({
|
|||||||
listClientRegistrationPartners(registrationId, accessToken)
|
listClientRegistrationPartners(registrationId, accessToken)
|
||||||
.then(setPartners)
|
.then(setPartners)
|
||||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
|
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
|
||||||
}, [registrationId, accessToken]);
|
}, [registrationId, accessToken, refreshKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -694,3 +694,29 @@ Isso exercita profundidade 3 no `AppShell` (Cadastro > Produtos > Grupos > Subgr
|
|||||||
|
|
||||||
### Pendências
|
### Pendências
|
||||||
Ajustes na tela de Cadastro de Cliente — usuário sinalizou que é o próximo passo, ainda sem especificar quais ajustes.
|
Ajustes na tela de Cadastro de Cliente — usuário sinalizou que é o próximo passo, ainda sem especificar quais ajustes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Busca automática de CNPJ (dados da empresa, sócios, inscrição estadual) no Cadastro de Cliente
|
||||||
|
|
||||||
|
Data: 2026-09-18. O ajuste pedido na leva anterior era este: usuário apontou `/home/handix/cadastro.md` (fora do repo), documentando como o legado OrçaFácil consulta CNPJ pra pré-preencher razão social/endereço/inscrição estadual e a lista de sócios (pra usar depois na confecção do contrato/assinatura), via duas APIs públicas gratuitas (cnpj.ws principal — única com inscrição estadual —, BrasilAPI como fallback).
|
||||||
|
|
||||||
|
**Achado real antes de implementar**: `pj_cnpj` (e `pf_cpf`/`pj_rep_cpf`) **não tinham nenhuma forma de ser gravados** — `UpdateClientRegistrationDto` os exclui deliberadamente, com um comentário no próprio código: "Changing the document itself would need a dedicated re-verification flow — never a side effect of a generic admin PATCH." Era exatamente essa lacuna, já prevista mas nunca fechada, que esta feature resolve.
|
||||||
|
|
||||||
|
### Backend (agente `eden-commercial`)
|
||||||
|
|
||||||
|
`packages/integrations/src/cnpj-lookup/lookup.ts` (novo adapter, `fetch` nativo do Node 22, sem dependência nova): `lookupCnpj(cnpj)` — cnpj.ws primário, BrasilAPI fallback só em erro de rede/5xx (404 explícito do primário = "não existe", não cai pro fallback), timeout de 10s. Endpoint novo `POST /v1/client-registrations/:id/cnpj-lookup` — uma ação só, dedicada (sem endpoint de "preview" separado, mais fiel ao "ao perder o foco já preenche" do legado): o servidor SEMPRE refaz a própria consulta autoritativa a partir do CNPJ recebido (nunca confia num payload do cliente dizendo o que "já encontrou"), grava `pj_cnpj` + todos os campos PJ + endereço via uma função de repositório nova e dedicada (`applyCnpjLookup`, o único lugar que pode tocar `pj_cnpj` — `COALESCE` em tudo mais pra nunca apagar dado já preenchido com um campo vazio da API), cria sócios novos com dedup por `document` (rodar de novo não duplica), devolve `{registration, partners}`. 404 se o CNPJ não for encontrado em nenhuma fonte (nenhum efeito colateral). 400 se o cadastro for PF.
|
||||||
|
|
||||||
|
**Decisão registrada**: sócios vindos do lookup usam `source: 'cnpj_lookup'`, não o `'brasilapi'` hardcoded do legado — já que cnpj.ws é a fonte primária real nesta porta, o valor do legado seria uma atribuição enganosa.
|
||||||
|
|
||||||
|
Verificado: `pnpm turbo run typecheck build` 28/28, suíte 100/100 (95→100, 5 testes novos com `lookupCnpj` mockado, sem dependência de rede). Testado ao vivo contra internet real: Petrobras (`33000167000101`, 10 sócios) e Banco do Brasil (`00000000000191`, 96 sócios) — dados corretos, inscrição estadual resolvida corretamente (ativa por UF, fallback pra primeira do array se nenhuma ativa); reexecução no mesmo cadastro não duplicou sócios; CNPJ inexistente devolveu 404 sem alterar nada. Achado ao vivo (não no plano original): cnpj.ws também devolve 429 (rate limit da própria API pública) além dos 5xx documentados — tratado igual, cai pro fallback normalmente.
|
||||||
|
|
||||||
|
### Frontend (agente `eden-frontend`)
|
||||||
|
|
||||||
|
`ClientRegistrationDetailPage.tsx`: campo CNPJ (dentro de "Dados PJ") agora tem dois estados — sem `pj_cnpj` ainda: `Input` editável + botão "Buscar CNPJ" (desabilitado até 14 dígitos); já com `pj_cnpj`: valor formatado somente-leitura + botão "Buscar novamente" (permite atualizar situação cadastral/endereço/IE que podem mudar com o tempo, sem redigitar o CNPJ). Sucesso mostra quantos sócios ficaram no cadastro; 404 mostra a mensagem exata da API num `Alert` de aviso; a lista de Sócios (`PartnersSection.tsx`, componente independente) ganhou um prop `refreshKey` pra recarregar depois de um lookup bem-sucedido, sem precisar de F5.
|
||||||
|
|
||||||
|
### Verificação
|
||||||
|
`pnpm turbo run typecheck build` 28/28. Suíte 100/100. Testado ao vivo em navegador real via Playwright: cadastro PJ novo → CNPJ da Petrobras → título da página, endereço, todos os campos PJ e os 10 sócios preenchidos automaticamente na tela, confirmado por screenshot, sem reload manual. Cadastro de teste desativado ao final.
|
||||||
|
|
||||||
|
### Pendências
|
||||||
|
Lookup de CPF pra pessoa física — fora de escopo, não foi pedido. Refresh automático da situação cadastral/endereço (ex. um job periódico) não existe — "Buscar novamente" é manual, sob demanda.
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export type {
|
|||||||
ListClientRegistrationsFilter,
|
ListClientRegistrationsFilter,
|
||||||
CreateClientRegistrationInput,
|
CreateClientRegistrationInput,
|
||||||
UpdateClientRegistrationProfileInput,
|
UpdateClientRegistrationProfileInput,
|
||||||
|
ApplyCnpjLookupInput,
|
||||||
} from "./repositories/client-registrations.js";
|
} from "./repositories/client-registrations.js";
|
||||||
export type {
|
export type {
|
||||||
ClientRegistrationPartner,
|
ClientRegistrationPartner,
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { query } from "../pool.js";
|
import { query } from "../pool.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). In the
|
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). `source`
|
||||||
* legacy this table is populated from a public CNPJ lookup (cnpj.ws/
|
* defaults to `'manual'` (DB default) for hand-entered partners; rows
|
||||||
* BrasilAPI); that integration is out of scope for this MVP (see the
|
* created by the CNPJ-lookup flow (`ClientRegistrationsService.
|
||||||
* migration comment — `source` defaults to `'manual'` here instead of the
|
* applyCnpjLookup`, `POST /v1/client-registrations/:id/cnpj-lookup`) carry
|
||||||
* legacy's `'brasilapi'`, since nothing here comes from an external
|
* `source = 'cnpj_lookup'` instead of the legacy's hardcoded `'brasilapi'`
|
||||||
* lookup). Only the backoffice-correction slice of the legacy flow
|
* — deliberately more accurate here, since the primary source in this
|
||||||
* (eden.md §2.5: correcting cpf/will_sign/signature_email after
|
* port is cnpj.ws, not BrasilAPI (see `packages/integrations/src/
|
||||||
* submission) is implemented — partners must be entered manually in this
|
* cnpj-lookup/lookup.ts`). The backoffice-correction slice of the legacy
|
||||||
* round.
|
* flow (eden.md §2.5: correcting cpf/will_sign/signature_email after
|
||||||
|
* submission) is also implemented below.
|
||||||
*/
|
*/
|
||||||
export interface ClientRegistrationPartner {
|
export interface ClientRegistrationPartner {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,19 +51,28 @@ export interface CreatePartnerInput {
|
|||||||
cpf?: string | null;
|
cpf?: string | null;
|
||||||
willSign?: boolean;
|
willSign?: boolean;
|
||||||
signatureEmail?: string | null;
|
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> {
|
export async function createPartner(input: CreatePartnerInput): Promise<ClientRegistrationPartner> {
|
||||||
const { rows } = await query<ClientRegistrationPartner>(
|
const { rows } = await query<ClientRegistrationPartner>(
|
||||||
`INSERT INTO client_registration_partners
|
`INSERT INTO client_registration_partners
|
||||||
(client_registration_id, name, document, qualification, cpf, will_sign, signature_email)
|
(client_registration_id, name, document, qualification, source, cpf, will_sign, signature_email)
|
||||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7)
|
VALUES ($1, $2, $3, $4, COALESCE($5, 'manual'), $6, COALESCE($7, false), $8)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
input.clientRegistrationId,
|
input.clientRegistrationId,
|
||||||
input.name,
|
input.name,
|
||||||
input.document ?? null,
|
input.document ?? null,
|
||||||
input.qualification ?? null,
|
input.qualification ?? null,
|
||||||
|
input.source ?? null,
|
||||||
input.cpf ?? null,
|
input.cpf ?? null,
|
||||||
input.willSign ?? null,
|
input.willSign ?? null,
|
||||||
input.signatureEmail ?? null,
|
input.signatureEmail ?? null,
|
||||||
@@ -73,6 +83,18 @@ export async function createPartner(input: CreatePartnerInput): Promise<ClientRe
|
|||||||
return partner;
|
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. */
|
/** eden.md §2.5: backoffice can correct only cpf/will_sign/signature_email post-submission. */
|
||||||
export interface UpdatePartnerInput {
|
export interface UpdatePartnerInput {
|
||||||
cpf?: string | null;
|
cpf?: string | null;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface PortabilityRange {
|
|||||||
* MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): deliberately
|
* MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): deliberately
|
||||||
* out of scope for this round — the public unauthenticated token-based
|
* out of scope for this round — the public unauthenticated token-based
|
||||||
* self-service form (`GET/POST /public-client-registration/:token`), file
|
* 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
|
* "official" `cnpj_address_*` Receita Federal address block, and the
|
||||||
* `*_externally_verified`/`*_verification_*` columns (only the format-check
|
* `*_externally_verified`/`*_verification_*` columns (only the format-check
|
||||||
* booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those all land
|
* booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those all land
|
||||||
@@ -339,3 +339,82 @@ export async function updateClientRegistrationProfile(
|
|||||||
);
|
);
|
||||||
return rows[0] ?? null;
|
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 "./smtp/mailer.js";
|
||||||
export * from "./pdf/renderer.js";
|
export * from "./pdf/renderer.js";
|
||||||
export * from "./pdf/merge.js";
|
export * from "./pdf/merge.js";
|
||||||
|
export * from "./cnpj-lookup/lookup.js";
|
||||||
|
|||||||
Reference in New Issue
Block a user