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

@@ -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<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 { 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<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(() => {
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 (
<div>
@@ -505,8 +538,59 @@ export function ClientRegistrationDetailPage() {
</Card>
) : (
<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">
<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
label="Razão social"
value={form.pjCompanyName}
@@ -605,7 +689,11 @@ export function ClientRegistrationDetailPage() {
{registration.person_type === "pj" ? (
<div className="mt-4">
<PartnersSection registrationId={registration.id} accessToken={accessToken} />
<PartnersSection
registrationId={registration.id}
accessToken={accessToken}
refreshKey={partnersRefreshKey}
/>
</div>
) : null}
</div>

View File

@@ -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<ClientRegistrationPartner[] | null>(null);
const [error, setError] = useState<string | null>(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();