feat(resellers): expande cadastro (dados PJ, endereço, contatos, sócios) + busca de CNPJ

Mesma expansão feita no Cadastro de Cliente, aplicada a Revenda:
razão social/nome fantasia, inscrição estadual, natureza jurídica,
endereço, contatos e sócios — tabela reseller_partners nova. Endpoint
POST /v1/resellers/:id/cnpj-lookup reaproveita o adapter lookupCnpj já
existente (@eden/integrations), sem duplicar a lógica de consulta.

Decisão deliberada: revenda não ganhou person_type (sempre tratada
como PJ) e document continua livremente editável via PATCH genérico
(diferente de client_registrations, que bloqueia pj_cnpj de propósito)
— a busca de CNPJ aqui é só conveniência adicional, não uma trava nova.

Suíte: 106/106 (100→106). pnpm turbo run typecheck build: 28/28.
Testado ao vivo com CNPJ real (Petrobras, 10 sócios).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:47:23 -03:00
parent 9e629bb394
commit 2ae61dba22
11 changed files with 973 additions and 8 deletions

View File

@@ -0,0 +1,13 @@
import { IsString, MinLength } from "class-validator";
/**
* Body for `POST /v1/resellers/: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.
*/
export class ApplyResellerCnpjLookupDto {
@IsString()
@MinLength(11)
cnpj!: string;
}

View File

@@ -0,0 +1,20 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
/**
* `source` is not client-settable — always defaults to `'manual'` (DB
* default, the `reseller_partners` migration) for partners entered through
* this endpoint. Partners populated by the CNPJ-lookup flow
* (`POST :id/cnpj-lookup`) go through a different, server-only path and
* are tagged `'cnpj_lookup'` instead — see `resellers.service.ts`.
*/
export class CreateResellerPartnerDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional() @IsString() document?: string;
@IsOptional() @IsString() qualification?: string;
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -1,4 +1,4 @@
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
import { IsBoolean, IsDateString, IsEmail, IsOptional, IsString, MinLength } from "class-validator";
export class CreateResellerDto {
@IsString()
@@ -8,4 +8,31 @@ export class CreateResellerDto {
@IsOptional() @IsString() document?: string;
@IsOptional() @IsEmail() contactEmail?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() tradeName?: string;
@IsOptional() @IsString() stateRegistration?: string;
@IsOptional() @IsString() municipalRegistration?: string;
@IsOptional() @IsDateString() openingDate?: string;
@IsOptional() @IsString() legalNature?: string;
@IsOptional() @IsString() cnpjSituation?: string;
@IsOptional() @IsString() mainActivity?: string;
@IsOptional() @IsString() addressZip?: string;
@IsOptional() @IsString() addressStreet?: string;
@IsOptional() @IsString() addressNumber?: string;
@IsOptional() @IsString() addressComplement?: string;
@IsOptional() @IsString() addressNeighborhood?: string;
@IsOptional() @IsString() addressCity?: string;
@IsOptional() @IsString() addressState?: string;
@IsOptional() @IsString() addressCountry?: string;
@IsOptional() @IsString() contactPrincipalName?: string;
@IsOptional() @IsEmail() contactPrincipalEmail?: string;
@IsOptional() @IsString() contactPrincipalPhone?: string;
@IsOptional() @IsString() contactFinancialName?: string;
@IsOptional() @IsEmail() contactFinancialEmail?: string;
@IsOptional() @IsString() contactFinancialPhone?: string;
@IsOptional() @IsString() contactTechnicalName?: string;
@IsOptional() @IsEmail() contactTechnicalEmail?: string;
@IsOptional() @IsString() contactTechnicalPhone?: string;
}

View File

@@ -0,0 +1,8 @@
import { IsBoolean, IsEmail, IsOptional, IsString } from "class-validator";
/** Same corrigible fields as client-registration partners: cpf/will_sign/signature_email. */
export class UpdateResellerPartnerDto {
@IsOptional() @IsString() cpf?: string;
@IsOptional() @IsBoolean() willSign?: boolean;
@IsOptional() @IsEmail() signatureEmail?: string;
}

View File

@@ -1,9 +1,12 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { ResellersService } from "./resellers.service.js";
import { CreateResellerDto } from "./dto/create-reseller.dto.js";
import { UpdateResellerDto } from "./dto/update-reseller.dto.js";
import { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
import { CreateResellerPartnerDto } from "./dto/create-reseller-partner.dto.js";
import { UpdateResellerPartnerDto } from "./dto/update-reseller-partner.dto.js";
import { ApplyResellerCnpjLookupDto } from "./dto/apply-reseller-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";
@@ -87,4 +90,40 @@ export class ResellersController {
unassignUser(@Body() dto: AssignUserToResellerDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.resellers.unassignUser(dto.userId, user.id);
}
@Get(":id/partners")
listPartners(@Param("id", ParseUUIDPipe) id: string) {
return this.resellers.listPartners(id);
}
@Post(":id/partners")
@RequirePermission("resellers", "edit")
createPartner(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CreateResellerPartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.resellers.createPartner(id, dto, user.id);
}
@Patch(":id/partners/:partnerId")
@RequirePermission("resellers", "edit")
updatePartner(
@Param("id", ParseUUIDPipe) id: string,
@Param("partnerId", ParseUUIDPipe) partnerId: string,
@Body() dto: UpdateResellerPartnerDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.resellers.updatePartner(id, partnerId, dto, user.id);
}
@Post(":id/cnpj-lookup")
@RequirePermission("resellers", "edit")
applyCnpjLookup(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ApplyResellerCnpjLookupDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.resellers.applyCnpjLookup(id, dto, user.id);
}
}

View File

@@ -1,9 +1,29 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { resellersRepo, usersRepo, auditRepo, type Reseller } from "@eden/database";
import {
resellersRepo,
resellerPartnersRepo,
usersRepo,
auditRepo,
type Reseller,
type ResellerPartner,
} from "@eden/database";
import { onlyDigits, isValidCPF, isValidCNPJ } from "@eden/domain-shared";
import { lookupCnpj } from "@eden/integrations";
import type { CreateResellerDto } from "./dto/create-reseller.dto.js";
import type { UpdateResellerDto } from "./dto/update-reseller.dto.js";
import type { AssignUserToResellerDto } from "./dto/assign-user-to-reseller.dto.js";
import type { CreateResellerPartnerDto } from "./dto/create-reseller-partner.dto.js";
import type { UpdateResellerPartnerDto } from "./dto/update-reseller-partner.dto.js";
import type { ApplyResellerCnpjLookupDto } from "./dto/apply-reseller-cnpj-lookup.dto.js";
/** Same tag used by client-registrations' CNPJ-lookup flow — cnpj.ws is the primary source in this port, not BrasilAPI. */
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;
}
@Injectable()
export class ResellersService {
@@ -26,6 +46,30 @@ export class ResellersService {
contactEmail: dto.contactEmail,
active: dto.active,
createdBy: actorId,
tradeName: dto.tradeName,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
openingDate: dto.openingDate,
legalNature: dto.legalNature,
cnpjSituation: dto.cnpjSituation,
mainActivity: dto.mainActivity,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
contactPrincipalName: dto.contactPrincipalName,
contactPrincipalEmail: dto.contactPrincipalEmail,
contactPrincipalPhone: dto.contactPrincipalPhone,
contactFinancialName: dto.contactFinancialName,
contactFinancialEmail: dto.contactFinancialEmail,
contactFinancialPhone: dto.contactFinancialPhone,
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEmail: dto.contactTechnicalEmail,
contactTechnicalPhone: dto.contactTechnicalPhone,
});
await auditRepo.appendAuditEvent({
@@ -48,6 +92,30 @@ export class ResellersService {
document: dto.document ? onlyDigits(dto.document) : undefined,
contactEmail: dto.contactEmail,
active: dto.active,
tradeName: dto.tradeName,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
openingDate: dto.openingDate,
legalNature: dto.legalNature,
cnpjSituation: dto.cnpjSituation,
mainActivity: dto.mainActivity,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
contactPrincipalName: dto.contactPrincipalName,
contactPrincipalEmail: dto.contactPrincipalEmail,
contactPrincipalPhone: dto.contactPrincipalPhone,
contactFinancialName: dto.contactFinancialName,
contactFinancialEmail: dto.contactFinancialEmail,
contactFinancialPhone: dto.contactFinancialPhone,
contactTechnicalName: dto.contactTechnicalName,
contactTechnicalEmail: dto.contactTechnicalEmail,
contactTechnicalPhone: dto.contactTechnicalPhone,
});
if (!reseller) throw new NotFoundException("Revenda não encontrada");
@@ -102,6 +170,141 @@ export class ResellersService {
return { userId, resellerId: null };
}
async listPartners(id: string): Promise<ResellerPartner[]> {
await this.get(id); // 404 if missing
return resellerPartnersRepo.listPartnersForReseller(id);
}
async createPartner(
id: string,
dto: CreateResellerPartnerDto,
actorId: string,
): Promise<ResellerPartner> {
await this.get(id);
const partner = await resellerPartnersRepo.createPartner({
resellerId: id,
name: dto.name,
document: dto.document,
qualification: dto.qualification,
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.partner_created",
resourceType: "reseller_partner",
resourceId: partner.id,
metadata: { resellerId: id },
});
return partner;
}
async updatePartner(
id: string,
partnerId: string,
dto: UpdateResellerPartnerDto,
actorId: string,
): Promise<ResellerPartner> {
await this.get(id);
const existing = await resellerPartnersRepo.getPartnerById(partnerId);
if (!existing || existing.reseller_id !== id) {
throw new NotFoundException("Sócio não encontrado");
}
const partner = await resellerPartnersRepo.updatePartner(partnerId, {
cpf: dto.cpf,
willSign: dto.willSign,
signatureEmail: dto.signatureEmail,
});
if (!partner) throw new NotFoundException("Sócio não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.partner_updated",
resourceType: "reseller_partner",
resourceId: partnerId,
metadata: { resellerId: id, fields: Object.keys(dto) },
});
return partner;
}
/**
* `POST /:id/cnpj-lookup` — same mechanics as
* `ClientRegistrationsService.applyCnpjLookup`: the server always
* re-runs the authoritative lookup itself from `dto.cnpj` — never trusts
* a client-submitted "already found" payload for persistence.
*/
async applyCnpjLookup(
id: string,
dto: ApplyResellerCnpjLookupDto,
actorId: string,
): Promise<{ reseller: Reseller; partners: ResellerPartner[] }> {
await this.get(id); // 404 if missing
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 resellersRepo.applyCnpjLookup(id, {
document: 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("Revenda não encontrada");
let partnersCreated = 0;
for (const partner of result.partners) {
const document = orNull(partner.document);
if (document) {
const existing = await resellerPartnersRepo.findPartnerByDocument(id, document);
if (existing) continue;
}
await resellerPartnersRepo.createPartner({
resellerId: id,
name: partner.name,
document,
qualification: orNull(partner.qualification),
source: CNPJ_LOOKUP_PARTNER_SOURCE,
});
partnersCreated += 1;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "reseller.cnpj_lookup_applied",
resourceType: "reseller",
resourceId: id,
metadata: { cnpj: normalizedCnpj, partnersFound: result.partners.length, partnersCreated },
});
const partners = await resellerPartnersRepo.listPartnersForReseller(id);
return { reseller: updated, partners };
}
/** Mirrors Companies' assertValidCnpjIfPresent — document is CPF or CNPJ here. */
private assertValidDocumentIfPresent(document: string | undefined): void {
if (!document) return;

View File

@@ -0,0 +1,214 @@
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/resellers/:id/cnpj-lookup — mirrors
* client-registrations-cnpj-lookup.spec.ts. `lookupCnpj` is mocked
* throughout (no network calls to cnpj.ws/BrasilAPI in this suite); a real
* call against the internet is done manually, once, outside of vitest.
*/
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- Revenda Lookup LTDA",
tradeName: "qa-test- Revenda Lookup",
openingDate: "2012-07-19",
legalNature: "Sociedade Empresária Limitada",
cnpjSituation: "ATIVA",
mainActivity: "Comércio varejista de equipamentos de telecomunicação",
address: {
zip: "04547-000",
street: "Av. Brigadeiro Faria Lima",
number: "2000",
complement: "Conjunto 20",
neighborhood: "Itaim Bibi",
city: "São Paulo",
state: "SP",
},
stateRegistration: "987.654.321.114",
partners: [
{ name: "Ciclana de Tal", document: "***111222**", qualification: "Sócio-Administrador" },
{ name: "Sicrano da Silva", document: "***333444**", qualification: "Sócio" },
],
};
describe("resellers — 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 createReseller(name: string) {
const res = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name })
.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 createReseller("qa-test- Revenda A");
const res = await agent(app)
.post(`/api/v1/resellers/${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.reseller.document).toBe("11222333000181");
expect(res.body.reseller.trade_name).toBe(FOUND_RESULT.tradeName);
expect(res.body.reseller.state_registration).toBe(FOUND_RESULT.stateRegistration);
expect(res.body.reseller.legal_nature).toBe(FOUND_RESULT.legalNature);
expect(res.body.reseller.cnpj_situation).toBe(FOUND_RESULT.cnpjSituation);
expect(res.body.reseller.main_activity).toBe(FOUND_RESULT.mainActivity);
expect(res.body.reseller.address_zip).toBe(FOUND_RESULT.address.zip);
expect(res.body.reseller.address_street).toBe(FOUND_RESULT.address.street);
expect(res.body.reseller.address_city).toBe(FOUND_RESULT.address.city);
expect(res.body.reseller.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(
["Ciclana de Tal", "Sicrano da Silva"].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/resellers/${id}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(getRes.body.document).toBe("11222333000181");
const partnersRes = await agent(app)
.get(`/api/v1/resellers/${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 createReseller("qa-test- Revenda B");
await agent(app)
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
.set(...bearer(admin.accessToken))
.send({ cnpj: "11222333000181" })
.expect(201);
const second = await agent(app)
.post(`/api/v1/resellers/${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/resellers/${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 createReseller("qa-test- Revenda C");
await agent(app)
.post(`/api/v1/resellers/${id}/cnpj-lookup`)
.set(...bearer(admin.accessToken))
.send({ cnpj: "99999999000199" })
.expect(404);
const getRes = await agent(app)
.get(`/api/v1/resellers/${id}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(getRes.body.document).toBeNull();
expect(getRes.body.trade_name).toBeNull();
const partnersRes = await agent(app)
.get(`/api/v1/resellers/${id}/partners`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(partnersRes.body).toHaveLength(0);
});
it("returns 404 for a reseller id that doesn't exist", async () => {
await agent(app)
.post(`/api/v1/resellers/${randomUUID()}/cnpj-lookup`)
.set(...bearer(admin.accessToken))
.send({ cnpj: "11222333000181" })
.expect(404);
expect(lookupCnpjMock).not.toHaveBeenCalled();
});
it("supports manual partner CRUD: create, list, update", async () => {
const id = await createReseller("qa-test- Revenda D");
const created = await agent(app)
.post(`/api/v1/resellers/${id}/partners`)
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Sócio Manual", document: "12345678909", qualification: "Sócio" })
.expect(201);
expect(created.body.source).toBe("manual");
expect(created.body.will_sign).toBe(false);
const listRes = await agent(app)
.get(`/api/v1/resellers/${id}/partners`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(listRes.body).toHaveLength(1);
const updated = await agent(app)
.patch(`/api/v1/resellers/${id}/partners/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ cpf: "12345678909", willSign: true, signatureEmail: "socio@example.com" })
.expect(200);
expect(updated.body.cpf).toBe("12345678909");
expect(updated.body.will_sign).toBe(true);
expect(updated.body.signature_email).toBe("socio@example.com");
});
it("returns 404 for a partner update targeting a partner that doesn't belong to the reseller", async () => {
const idA = await createReseller("qa-test- Revenda E1");
const idB = await createReseller("qa-test- Revenda E2");
const created = await agent(app)
.post(`/api/v1/resellers/${idA}/partners`)
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Sócio Cruzado" })
.expect(201);
await agent(app)
.patch(`/api/v1/resellers/${idB}/partners/${created.body.id}`)
.set(...bearer(admin.accessToken))
.send({ willSign: true })
.expect(404);
});
});