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

View File

@@ -0,0 +1,104 @@
/**
* Expands the minimalist operational `resellers` cadastro (eden.md §1.7)
* with the same PJ profile fields, address block, three contact roles, and
* sócios/QSA table already shipped for `client_registrations` in this
* session — enabling the same CNPJ-lookup autofill
* (`packages/integrations/src/cnpj-lookup/lookup.ts`, `lookupCnpj`) for
* Revenda. Mirrors `client_registrations`' `pj_*`/`address_*`/`contact_*`
* shape, but WITHOUT the `pj_` prefix — there's no PF/PJ ambiguity on this
* table (a reseller is always a company; `resellers.name` already plays
* the role `pj_company_name` plays there, so only `trade_name` is new for
* the "nome" side). No `person_type` column is added — deliberate, see
* task notes; a reseller cadastro doesn't bifurcate PF/PJ.
*
* Deliberate divergence from `client_registrations`: `document` here stays
* freely editable via the existing generic `PATCH /v1/resellers/:id` —
* unlike `pj_cnpj`/`pf_cpf` there, which are locked out of the generic
* admin PATCH. The CNPJ-lookup endpoint added alongside this migration
* (`POST /v1/resellers/:id/cnpj-lookup`) is purely an additional
* convenience action, not a replacement for manual editing.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE resellers
ADD COLUMN trade_name TEXT,
ADD COLUMN state_registration TEXT,
ADD COLUMN municipal_registration TEXT,
ADD COLUMN opening_date DATE,
ADD COLUMN legal_nature TEXT,
ADD COLUMN cnpj_situation TEXT,
ADD COLUMN main_activity TEXT,
ADD COLUMN address_zip TEXT,
ADD COLUMN address_street TEXT,
ADD COLUMN address_number TEXT,
ADD COLUMN address_complement TEXT,
ADD COLUMN address_neighborhood TEXT,
ADD COLUMN address_city TEXT,
ADD COLUMN address_state TEXT,
ADD COLUMN address_country TEXT NOT NULL DEFAULT 'Brasil',
ADD COLUMN contact_principal_name TEXT,
ADD COLUMN contact_principal_email TEXT,
ADD COLUMN contact_principal_phone TEXT,
ADD COLUMN contact_financial_name TEXT,
ADD COLUMN contact_financial_email TEXT,
ADD COLUMN contact_financial_phone TEXT,
ADD COLUMN contact_technical_name TEXT,
ADD COLUMN contact_technical_email TEXT,
ADD COLUMN contact_technical_phone TEXT;
COMMENT ON COLUMN resellers.document IS
'CPF/CNPJ livre (sem CHECK de dígito no banco, validado na camada de aplicação) — ao contrário de client_registrations.pj_cnpj/pf_cpf, continua editável via PATCH genérico (decisão deliberada, não replicar a trava de lá aqui).';
CREATE TABLE reseller_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reseller_id UUID NOT NULL REFERENCES resellers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
document TEXT,
qualification TEXT,
source TEXT NOT NULL DEFAULT 'manual',
cpf TEXT,
will_sign BOOLEAN NOT NULL DEFAULT false,
signature_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE reseller_partners IS
'Sócios/QSA da revenda — mesmo shape de client_registration_partners. source default "manual"; a rota de CNPJ-lookup grava "cnpj_lookup" para os sócios que vierem da API pública.';
CREATE INDEX idx_reseller_partners_reseller ON reseller_partners(reseller_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS reseller_partners;
ALTER TABLE resellers
DROP COLUMN IF EXISTS trade_name,
DROP COLUMN IF EXISTS state_registration,
DROP COLUMN IF EXISTS municipal_registration,
DROP COLUMN IF EXISTS opening_date,
DROP COLUMN IF EXISTS legal_nature,
DROP COLUMN IF EXISTS cnpj_situation,
DROP COLUMN IF EXISTS main_activity,
DROP COLUMN IF EXISTS address_zip,
DROP COLUMN IF EXISTS address_street,
DROP COLUMN IF EXISTS address_number,
DROP COLUMN IF EXISTS address_complement,
DROP COLUMN IF EXISTS address_neighborhood,
DROP COLUMN IF EXISTS address_city,
DROP COLUMN IF EXISTS address_state,
DROP COLUMN IF EXISTS address_country,
DROP COLUMN IF EXISTS contact_principal_name,
DROP COLUMN IF EXISTS contact_principal_email,
DROP COLUMN IF EXISTS contact_principal_phone,
DROP COLUMN IF EXISTS contact_financial_name,
DROP COLUMN IF EXISTS contact_financial_email,
DROP COLUMN IF EXISTS contact_financial_phone,
DROP COLUMN IF EXISTS contact_technical_name,
DROP COLUMN IF EXISTS contact_technical_email,
DROP COLUMN IF EXISTS contact_technical_phone;
`);
};

View File

@@ -8,6 +8,7 @@ export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js";
export * as productCatalogsRepo from "./repositories/product-catalogs.js";
export * as resellersRepo from "./repositories/resellers.js";
export * as resellerPartnersRepo from "./repositories/reseller-partners.js";
export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
@@ -55,7 +56,17 @@ export type {
CreateUnitInput,
UpdateUnitInput,
} from "./repositories/product-catalogs.js";
export type { Reseller, CreateResellerInput, UpdateResellerInput } from "./repositories/resellers.js";
export type {
Reseller,
CreateResellerInput,
UpdateResellerInput,
ApplyCnpjLookupInput as ApplyResellerCnpjLookupInput,
} from "./repositories/resellers.js";
export type {
ResellerPartner,
CreatePartnerInput as CreateResellerPartnerInput,
UpdatePartnerInput as UpdateResellerPartnerInput,
} from "./repositories/reseller-partners.js";
export type {
Lead,
ListLeadsFilter,

View File

@@ -0,0 +1,107 @@
import { query } from "../pool.js";
/**
* `reseller_partners` — sócios/QSA da revenda, mesmo shape de
* `client_registration_partners`. `source` defaults to `'manual'` (DB
* default) for hand-entered partners; rows created by the CNPJ-lookup flow
* (`ResellersService.applyCnpjLookup`, `POST /v1/resellers/:id/cnpj-lookup`)
* carry `source = 'cnpj_lookup'`.
*/
export interface ResellerPartner {
id: string;
reseller_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: Date;
}
export async function listPartnersForReseller(resellerId: string): Promise<ResellerPartner[]> {
const { rows } = await query<ResellerPartner>(
"SELECT * FROM reseller_partners WHERE reseller_id = $1 ORDER BY created_at ASC",
[resellerId],
);
return rows;
}
export async function getPartnerById(id: string): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>("SELECT * FROM reseller_partners WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreatePartnerInput {
resellerId: string;
name: string;
document?: string | null;
qualification?: string | null;
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
/**
* Server-only — never accepted from `CreatePartnerDto`. Defaults to
* `'manual'` (DB default) when omitted; the CNPJ-lookup flow
* (`ResellersService.applyCnpjLookup`) is the only caller that passes a
* non-default value.
*/
source?: string;
}
export async function createPartner(input: CreatePartnerInput): Promise<ResellerPartner> {
const { rows } = await query<ResellerPartner>(
`INSERT INTO reseller_partners
(reseller_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.resellerId,
input.name,
input.document ?? null,
input.qualification ?? null,
input.source ?? null,
input.cpf ?? null,
input.willSign ?? null,
input.signatureEmail ?? null,
],
);
const partner = rows[0];
if (!partner) throw new Error("failed to create reseller partner");
return partner;
}
/** Dedup key for the CNPJ-lookup flow: don't recreate a partner already recorded for this reseller+document. */
export async function findPartnerByDocument(
resellerId: string,
document: string,
): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>(
"SELECT * FROM reseller_partners WHERE reseller_id = $1 AND document = $2 LIMIT 1",
[resellerId, document],
);
return rows[0] ?? null;
}
export interface UpdatePartnerInput {
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function updatePartner(
id: string,
input: UpdatePartnerInput,
): Promise<ResellerPartner | null> {
const { rows } = await query<ResellerPartner>(
`UPDATE reseller_partners SET
cpf = COALESCE($2, cpf),
will_sign = COALESCE($3, will_sign),
signature_email = COALESCE($4, signature_email)
WHERE id = $1
RETURNING *`,
[id, input.cpf ?? null, input.willSign ?? null, input.signatureEmail ?? null],
);
return rows[0] ?? null;
}

View File

@@ -9,6 +9,30 @@ export interface Reseller {
document: string | null;
contact_email: string | null;
active: boolean;
trade_name: string | null;
state_registration: string | null;
municipal_registration: string | null;
opening_date: Date | null;
legal_nature: string | null;
cnpj_situation: string | null;
main_activity: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
}
export async function listResellers(includeInactive = false): Promise<Reseller[]> {
@@ -31,12 +55,54 @@ export interface CreateResellerInput {
contactEmail?: string | null;
active?: boolean;
createdBy?: string | null;
tradeName?: string | null;
stateRegistration?: string | null;
municipalRegistration?: 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;
addressCountry?: string | null;
contactPrincipalName?: string | null;
contactPrincipalEmail?: string | null;
contactPrincipalPhone?: string | null;
contactFinancialName?: string | null;
contactFinancialEmail?: string | null;
contactFinancialPhone?: string | null;
contactTechnicalName?: string | null;
contactTechnicalEmail?: string | null;
contactTechnicalPhone?: string | null;
}
export async function createReseller(input: CreateResellerInput): Promise<Reseller> {
const { rows } = await query<Reseller>(
`INSERT INTO resellers (name, document, contact_email, active, created_by)
VALUES ($1, $2, $3, COALESCE($4, true), $5)
`INSERT INTO resellers (
name, document, contact_email, active, created_by,
trade_name, state_registration, municipal_registration, opening_date,
legal_nature, cnpj_situation, main_activity,
address_zip, address_street, address_number, address_complement,
address_neighborhood, address_city, address_state, address_country,
contact_principal_name, contact_principal_email, contact_principal_phone,
contact_financial_name, contact_financial_email, contact_financial_phone,
contact_technical_name, contact_technical_email, contact_technical_phone
)
VALUES (
$1, $2, $3, COALESCE($4, true), $5,
$6, $7, $8, $9::date,
$10, $11, $12,
$13, $14, $15, $16,
$17, $18, $19, COALESCE($20, 'Brasil'),
$21, $22, $23,
$24, $25, $26,
$27, $28, $29
)
RETURNING *`,
[
input.name,
@@ -44,6 +110,30 @@ export async function createReseller(input: CreateResellerInput): Promise<Resell
input.contactEmail ?? null,
input.active ?? null,
input.createdBy ?? null,
input.tradeName ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.openingDate ?? null,
input.legalNature ?? null,
input.cnpjSituation ?? null,
input.mainActivity ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
],
);
const reseller = rows[0];
@@ -62,10 +152,139 @@ export async function updateReseller(
name = COALESCE($2, name),
document = COALESCE($3, document),
contact_email = COALESCE($4, contact_email),
active = COALESCE($5, active)
active = COALESCE($5, active),
trade_name = COALESCE($6, trade_name),
state_registration = COALESCE($7, state_registration),
municipal_registration = COALESCE($8, municipal_registration),
opening_date = COALESCE($9::date, opening_date),
legal_nature = COALESCE($10, legal_nature),
cnpj_situation = COALESCE($11, cnpj_situation),
main_activity = COALESCE($12, main_activity),
address_zip = COALESCE($13, address_zip),
address_street = COALESCE($14, address_street),
address_number = COALESCE($15, address_number),
address_complement = COALESCE($16, address_complement),
address_neighborhood = COALESCE($17, address_neighborhood),
address_city = COALESCE($18, address_city),
address_state = COALESCE($19, address_state),
address_country = COALESCE($20, address_country),
contact_principal_name = COALESCE($21, contact_principal_name),
contact_principal_email = COALESCE($22, contact_principal_email),
contact_principal_phone = COALESCE($23, contact_principal_phone),
contact_financial_name = COALESCE($24, contact_financial_name),
contact_financial_email = COALESCE($25, contact_financial_email),
contact_financial_phone = COALESCE($26, contact_financial_phone),
contact_technical_name = COALESCE($27, contact_technical_name),
contact_technical_email = COALESCE($28, contact_technical_email),
contact_technical_phone = COALESCE($29, contact_technical_phone)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.document ?? null, input.contactEmail ?? null, input.active ?? null],
[
id,
input.name ?? null,
input.document ?? null,
input.contactEmail ?? null,
input.active ?? null,
input.tradeName ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.openingDate ?? null,
input.legalNature ?? null,
input.cnpjSituation ?? null,
input.mainActivity ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
],
);
return rows[0] ?? null;
}
/**
* Dedicated CNPJ-lookup apply — mirrors
* `client-registrations.ts`'s `applyCnpjLookup`, but unlike there
* `document` is NOT structurally locked out of the generic update above;
* this function is purely a convenience path that also happens to set
* `document`, always re-resolved from the authoritative lookup itself
* (`ResellersService.applyCnpjLookup` never trusts a client-submitted
* "already looked up" payload). Every other field is `COALESCE`d against
* the existing column, same discipline as the client-registrations path:
* a field the lookup source didn't bring back never clobbers a value
* already on file.
*/
export interface ApplyCnpjLookupInput {
document: 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<Reseller | null> {
const { rows } = await query<Reseller>(
`UPDATE resellers SET
document = $2,
name = COALESCE($3, name),
trade_name = COALESCE($4, trade_name),
state_registration = COALESCE($5, state_registration),
opening_date = COALESCE($6::date, opening_date),
legal_nature = COALESCE($7, legal_nature),
cnpj_situation = COALESCE($8, cnpj_situation),
main_activity = COALESCE($9, 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.document,
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;
}