diff --git a/apps/api/src/resellers/dto/apply-reseller-cnpj-lookup.dto.ts b/apps/api/src/resellers/dto/apply-reseller-cnpj-lookup.dto.ts new file mode 100644 index 0000000..ce0ea52 --- /dev/null +++ b/apps/api/src/resellers/dto/apply-reseller-cnpj-lookup.dto.ts @@ -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; +} diff --git a/apps/api/src/resellers/dto/create-reseller-partner.dto.ts b/apps/api/src/resellers/dto/create-reseller-partner.dto.ts new file mode 100644 index 0000000..4b4162e --- /dev/null +++ b/apps/api/src/resellers/dto/create-reseller-partner.dto.ts @@ -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; +} diff --git a/apps/api/src/resellers/dto/create-reseller.dto.ts b/apps/api/src/resellers/dto/create-reseller.dto.ts index 8603a99..2f8ef37 100644 --- a/apps/api/src/resellers/dto/create-reseller.dto.ts +++ b/apps/api/src/resellers/dto/create-reseller.dto.ts @@ -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; } diff --git a/apps/api/src/resellers/dto/update-reseller-partner.dto.ts b/apps/api/src/resellers/dto/update-reseller-partner.dto.ts new file mode 100644 index 0000000..095d967 --- /dev/null +++ b/apps/api/src/resellers/dto/update-reseller-partner.dto.ts @@ -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; +} diff --git a/apps/api/src/resellers/resellers.controller.ts b/apps/api/src/resellers/resellers.controller.ts index 4f5fde1..d5b16da 100644 --- a/apps/api/src/resellers/resellers.controller.ts +++ b/apps/api/src/resellers/resellers.controller.ts @@ -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); + } } diff --git a/apps/api/src/resellers/resellers.service.ts b/apps/api/src/resellers/resellers.service.ts index d9bf02d..ff7ab0a 100644 --- a/apps/api/src/resellers/resellers.service.ts +++ b/apps/api/src/resellers/resellers.service.ts @@ -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 { + await this.get(id); // 404 if missing + return resellerPartnersRepo.listPartnersForReseller(id); + } + + async createPartner( + id: string, + dto: CreateResellerPartnerDto, + actorId: string, + ): Promise { + 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 { + 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; diff --git a/apps/api/test/resellers-cnpj-lookup.spec.ts b/apps/api/test/resellers-cnpj-lookup.spec.ts new file mode 100644 index 0000000..5a3c4cc --- /dev/null +++ b/apps/api/test/resellers-cnpj-lookup.spec.ts @@ -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>(); + +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- 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); + }); +}); diff --git a/packages/database/migrations/1789700000000_resellers-expansion.cjs b/packages/database/migrations/1789700000000_resellers-expansion.cjs new file mode 100644 index 0000000..a23c32a --- /dev/null +++ b/packages/database/migrations/1789700000000_resellers-expansion.cjs @@ -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; + `); +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 1f4ac2e..057eca5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -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, diff --git a/packages/database/src/repositories/reseller-partners.ts b/packages/database/src/repositories/reseller-partners.ts new file mode 100644 index 0000000..8ae6b51 --- /dev/null +++ b/packages/database/src/repositories/reseller-partners.ts @@ -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 { + const { rows } = await query( + "SELECT * FROM reseller_partners WHERE reseller_id = $1 ORDER BY created_at ASC", + [resellerId], + ); + return rows; +} + +export async function getPartnerById(id: string): Promise { + const { rows } = await query("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 { + const { rows } = await query( + `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 { + const { rows } = await query( + "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 { + const { rows } = await query( + `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; +} diff --git a/packages/database/src/repositories/resellers.ts b/packages/database/src/repositories/resellers.ts index 2441a79..530cca4 100644 --- a/packages/database/src/repositories/resellers.ts +++ b/packages/database/src/repositories/resellers.ts @@ -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 { @@ -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 { const { rows } = await query( - `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 { + const { rows } = await query( + `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; }