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:
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user