diff --git a/apps/api/src/client-registrations/client-registrations.controller.ts b/apps/api/src/client-registrations/client-registrations.controller.ts index d386eba..be59614 100644 --- a/apps/api/src/client-registrations/client-registrations.controller.ts +++ b/apps/api/src/client-registrations/client-registrations.controller.ts @@ -5,6 +5,7 @@ import { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-re import { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js"; import { CreatePartnerDto } from "./dto/create-partner.dto.js"; import { UpdatePartnerDto } from "./dto/update-partner.dto.js"; +import { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js"; import { JwtAuthGuard } from "../auth/jwt-auth.guard.js"; import { PermissionGuard } from "../auth/permission.guard.js"; import { RequirePermission } from "../auth/decorators/require-permission.decorator.js"; @@ -18,8 +19,9 @@ import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; * the `client-registrations` resource permission, since this data carries * customer PII (CPF/CNPJ, contacts, address). Deliberately out of scope * for this round: the public unauthenticated token-based self-service - * form, file attachments, CNPJ/CEP lookup — see the migration and - * repository comments for the full list. + * form, file attachments, CEP-only lookup — see the migration and + * repository comments for the full list. CNPJ lookup (`POST + * :id/cnpj-lookup`) IS implemented — see `client-registrations.service.ts`. */ @ApiTags("client-registrations") @ApiBearerAuth() @@ -85,4 +87,14 @@ export class ClientRegistrationsController { ) { return this.clientRegistrations.updatePartner(id, partnerId, dto, user); } + + @Post(":id/cnpj-lookup") + @RequirePermission("client-registrations", "edit") + applyCnpjLookup( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ApplyCnpjLookupDto, + @CurrentUser() user: EdenAuthenticatedUser, + ) { + return this.clientRegistrations.applyCnpjLookup(id, dto, user); + } } diff --git a/apps/api/src/client-registrations/client-registrations.service.ts b/apps/api/src/client-registrations/client-registrations.service.ts index 4e76fd6..078d726 100644 --- a/apps/api/src/client-registrations/client-registrations.service.ts +++ b/apps/api/src/client-registrations/client-registrations.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { auditRepo, clientRegistrationsRepo, @@ -6,12 +6,29 @@ import { type ClientRegistration, type ClientRegistrationPartner, } from "@eden/database"; +import { lookupCnpj } from "@eden/integrations"; import { maxGrantedScope } from "../auth/permissions.js"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; import type { CreateDirectClientRegistrationDto } from "./dto/create-direct-client-registration.dto.js"; import type { UpdateClientRegistrationDto } from "./dto/update-client-registration.dto.js"; import type { CreatePartnerDto } from "./dto/create-partner.dto.js"; import type { UpdatePartnerDto } from "./dto/update-partner.dto.js"; +import type { ApplyCnpjLookupDto } from "./dto/apply-cnpj-lookup.dto.js"; + +/** + * Source tag for partners created by the CNPJ-lookup flow below — + * deliberately not the legacy's hardcoded `'brasilapi'` (see + * `client-registration-partners.ts`'s module comment): cnpj.ws is the + * primary source in this port, so a fixed `'brasilapi'` tag would + * misattribute provenance on the (common) success path. + */ +const CNPJ_LOOKUP_PARTNER_SOURCE = "cnpj_lookup"; + +/** `undefined`/blank → `null`, so a field the lookup didn't bring back never clobbers an existing value (COALESCE in the repo). */ +function orNull(value: string | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} type ResolvedScope = | { kind: "all" } @@ -234,4 +251,78 @@ export class ClientRegistrationsService { return partner; } + + /** + * `POST /:id/cnpj-lookup` — single dedicated action that resolves and + * applies CNPJ data in one step (no separate "preview" endpoint; mirrors + * the legacy's on-blur autofill). The server always re-runs the + * authoritative lookup itself from `dto.cnpj` — never trusts a + * client-submitted "already found" payload for persistence (Master + * Prompt §23 / this file's class-level discipline). + */ + async applyCnpjLookup( + id: string, + dto: ApplyCnpjLookupDto, + user: EdenAuthenticatedUser, + ): Promise<{ registration: ClientRegistration; partners: ClientRegistrationPartner[] }> { + const registration = await this.getForEdit(id, user); + if (registration.person_type !== "pj") { + throw new BadRequestException("Lookup de CNPJ só se aplica a cadastros de pessoa jurídica (PJ)"); + } + + const result = await lookupCnpj(dto.cnpj); + if (!result) { + throw new NotFoundException("CNPJ não encontrado — preencha os dados manualmente."); + } + + const normalizedCnpj = dto.cnpj.replace(/\D/g, ""); + + const updated = await clientRegistrationsRepo.applyCnpjLookup(id, { + cnpj: normalizedCnpj, + companyName: orNull(result.companyName), + tradeName: orNull(result.tradeName), + stateRegistration: orNull(result.stateRegistration), + openingDate: orNull(result.openingDate), + legalNature: orNull(result.legalNature), + cnpjSituation: orNull(result.cnpjSituation), + mainActivity: orNull(result.mainActivity), + addressZip: orNull(result.address.zip), + addressStreet: orNull(result.address.street), + addressNumber: orNull(result.address.number), + addressComplement: orNull(result.address.complement), + addressNeighborhood: orNull(result.address.neighborhood), + addressCity: orNull(result.address.city), + addressState: orNull(result.address.state), + }); + if (!updated) throw new NotFoundException("Cadastro de cliente não encontrado"); + + let partnersCreated = 0; + for (const partner of result.partners) { + const document = orNull(partner.document); + if (document) { + const existing = await clientRegistrationPartnersRepo.findPartnerByDocument(id, document); + if (existing) continue; + } + await clientRegistrationPartnersRepo.createPartner({ + clientRegistrationId: id, + name: partner.name, + document, + qualification: orNull(partner.qualification), + source: CNPJ_LOOKUP_PARTNER_SOURCE, + }); + partnersCreated += 1; + } + + await auditRepo.appendAuditEvent({ + actorType: "user", + actorId: user.id, + action: "client_registration.cnpj_lookup_applied", + resourceType: "client_registration", + resourceId: id, + metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated }, + }); + + const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(id); + return { registration: updated, partners }; + } } diff --git a/apps/api/src/client-registrations/dto/apply-cnpj-lookup.dto.ts b/apps/api/src/client-registrations/dto/apply-cnpj-lookup.dto.ts new file mode 100644 index 0000000..a05aa08 --- /dev/null +++ b/apps/api/src/client-registrations/dto/apply-cnpj-lookup.dto.ts @@ -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; +} diff --git a/apps/api/src/client-registrations/dto/create-partner.dto.ts b/apps/api/src/client-registrations/dto/create-partner.dto.ts index 8c12470..dc1c59c 100644 --- a/apps/api/src/client-registrations/dto/create-partner.dto.ts +++ b/apps/api/src/client-registrations/dto/create-partner.dto.ts @@ -3,8 +3,11 @@ import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-valid /** * `source` is not client-settable — always defaults to `'manual'` * (DB default, packages/database/migrations/*_client-registrations.cjs) - * since this MVP has no CNPJ-lookup integration to attribute it to - * (eden.md §1.2 §4 documents the legacy default as `'brasilapi'`). + * for partners entered through this endpoint. Partners populated by the + * CNPJ-lookup flow (`POST :id/cnpj-lookup`) go through a different, + * server-only path (`clientRegistrationPartnersRepo.createPartner`'s + * `source` param) and are tagged `'cnpj_lookup'` instead — see + * `client-registrations.service.ts`. */ export class CreatePartnerDto { @IsString() diff --git a/apps/api/test/client-registrations-cnpj-lookup.spec.ts b/apps/api/test/client-registrations-cnpj-lookup.spec.ts new file mode 100644 index 0000000..4e35db0 --- /dev/null +++ b/apps/api/test/client-registrations-cnpj-lookup.spec.ts @@ -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>(); + +vi.mock("@eden/integrations", async () => { + const actual = await vi.importActual("@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(); + }); +}); diff --git a/apps/core-web/src/lib/api/client-registrations.ts b/apps/core-web/src/lib/api/client-registrations.ts index 882ed3f..42faa25 100644 --- a/apps/core-web/src/lib/api/client-registrations.ts +++ b/apps/core-web/src/lib/api/client-registrations.ts @@ -264,3 +264,28 @@ export function updateClientRegistrationPartner( { 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 { + return apiRequest( + `/client-registrations/${id}/cnpj-lookup`, + { method: "POST", body: JSON.stringify({ cnpj }) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx b/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx index 89e23c5..93f0c33 100644 --- a/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx +++ b/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx @@ -6,6 +6,7 @@ import type { BadgeVariant } from "@eden/ui"; import { useAuth } from "../../lib/auth/AuthContext.js"; import { ApiError } from "../../lib/api/http.js"; import { + applyClientRegistrationCnpjLookup, getClientRegistration, updateClientRegistration, REGISTRATION_STATUSES, @@ -174,6 +175,13 @@ export function ClientRegistrationDetailPage() { const [saveSuccess, setSaveSuccess] = useState(false); const [submitting, setSubmitting] = useState(false); + const [cnpjInput, setCnpjInput] = useState(""); + const [cnpjLookupLoading, setCnpjLookupLoading] = useState(false); + const [cnpjLookupError, setCnpjLookupError] = useState(null); + const [cnpjLookupNotFound, setCnpjLookupNotFound] = useState(null); + const [cnpjLookupSuccess, setCnpjLookupSuccess] = useState(null); + const [partnersRefreshKey, setPartnersRefreshKey] = useState(0); + const load = useCallback(() => { if (!id || !accessToken) return; 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) { return (
@@ -505,8 +538,59 @@ export function ClientRegistrationDetailPage() { ) : ( + {cnpjLookupError ? ( + + {cnpjLookupError} + + ) : null} + {cnpjLookupNotFound ? ( + + {cnpjLookupNotFound} + + ) : null} + {cnpjLookupSuccess ? ( + + {cnpjLookupSuccess} + + ) : null}
- + {registration.pj_cnpj ? ( +
+
+ +
+ +
+ ) : ( +
+
+ setCnpjInput(e.target.value)} + placeholder="00.000.000/0000-00" + /> +
+ +
+ )} - +
) : null}
diff --git a/apps/core-web/src/routes/client-registrations/PartnersSection.tsx b/apps/core-web/src/routes/client-registrations/PartnersSection.tsx index 031e5a5..2d2f799 100644 --- a/apps/core-web/src/routes/client-registrations/PartnersSection.tsx +++ b/apps/core-web/src/routes/client-registrations/PartnersSection.tsx @@ -41,9 +41,12 @@ interface EditState { export function PartnersSection({ registrationId, accessToken, + refreshKey, }: { registrationId: string; 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(null); const [error, setError] = useState(null); @@ -63,7 +66,7 @@ export function PartnersSection({ listClientRegistrationPartners(registrationId, accessToken) .then(setPartners) .catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios.")); - }, [registrationId, accessToken]); + }, [registrationId, accessToken, refreshKey]); useEffect(() => { load(); diff --git a/docs/progress.md b/docs/progress.md index c81c28e..a9c9054 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -694,3 +694,29 @@ Isso exercita profundidade 3 no `AppShell` (Cadastro > Produtos > Grupos > Subgr ### Pendências 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. diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 83aa1b7..1f4ac2e 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -97,6 +97,7 @@ export type { ListClientRegistrationsFilter, CreateClientRegistrationInput, UpdateClientRegistrationProfileInput, + ApplyCnpjLookupInput, } from "./repositories/client-registrations.js"; export type { ClientRegistrationPartner, diff --git a/packages/database/src/repositories/client-registration-partners.ts b/packages/database/src/repositories/client-registration-partners.ts index 023a631..f1ed15b 100644 --- a/packages/database/src/repositories/client-registration-partners.ts +++ b/packages/database/src/repositories/client-registration-partners.ts @@ -1,15 +1,16 @@ import { query } from "../pool.js"; /** - * `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). In the - * legacy this table is populated from a public CNPJ lookup (cnpj.ws/ - * BrasilAPI); that integration is out of scope for this MVP (see the - * migration comment — `source` defaults to `'manual'` here instead of the - * legacy's `'brasilapi'`, since nothing here comes from an external - * lookup). Only the backoffice-correction slice of the legacy flow - * (eden.md §2.5: correcting cpf/will_sign/signature_email after - * submission) is implemented — partners must be entered manually in this - * round. + * `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). `source` + * defaults to `'manual'` (DB default) for hand-entered partners; rows + * created by the CNPJ-lookup flow (`ClientRegistrationsService. + * applyCnpjLookup`, `POST /v1/client-registrations/:id/cnpj-lookup`) carry + * `source = 'cnpj_lookup'` instead of the legacy's hardcoded `'brasilapi'` + * — deliberately more accurate here, since the primary source in this + * port is cnpj.ws, not BrasilAPI (see `packages/integrations/src/ + * cnpj-lookup/lookup.ts`). The backoffice-correction slice of the legacy + * flow (eden.md §2.5: correcting cpf/will_sign/signature_email after + * submission) is also implemented below. */ export interface ClientRegistrationPartner { id: string; @@ -50,19 +51,28 @@ export interface CreatePartnerInput { cpf?: string | null; willSign?: boolean; 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 { const { rows } = await query( `INSERT INTO client_registration_partners - (client_registration_id, name, document, qualification, cpf, will_sign, signature_email) - VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7) + (client_registration_id, name, document, qualification, source, cpf, will_sign, signature_email) + VALUES ($1, $2, $3, $4, COALESCE($5, 'manual'), $6, COALESCE($7, false), $8) RETURNING *`, [ input.clientRegistrationId, input.name, input.document ?? null, input.qualification ?? null, + input.source ?? null, input.cpf ?? null, input.willSign ?? null, input.signatureEmail ?? null, @@ -73,6 +83,18 @@ export async function createPartner(input: CreatePartnerInput): Promise { + const { rows } = await query( + "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. */ export interface UpdatePartnerInput { cpf?: string | null; diff --git a/packages/database/src/repositories/client-registrations.ts b/packages/database/src/repositories/client-registrations.ts index 6855887..6c56efb 100644 --- a/packages/database/src/repositories/client-registrations.ts +++ b/packages/database/src/repositories/client-registrations.ts @@ -14,7 +14,7 @@ export interface PortabilityRange { * MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): deliberately * out of scope for this round — the public unauthenticated token-based * 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 * `*_externally_verified`/`*_verification_*` columns (only the format-check * booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those all land @@ -339,3 +339,82 @@ export async function updateClientRegistrationProfile( ); 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 { + const { rows } = await query( + `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; +} diff --git a/packages/integrations/src/cnpj-lookup/lookup.ts b/packages/integrations/src/cnpj-lookup/lookup.ts new file mode 100644 index 0000000..efcad99 --- /dev/null +++ b/packages/integrations/src/cnpj-lookup/lookup.ts @@ -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 { + 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; + } +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 6e22ac5..5dd936e 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -8,3 +8,4 @@ export * from "./s3/client.js"; export * from "./smtp/mailer.js"; export * from "./pdf/renderer.js"; export * from "./pdf/merge.js"; +export * from "./cnpj-lookup/lookup.js";