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 { 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);
}
} }

View File

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

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'` * `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()

View 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();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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