diff --git a/apps/api/src/signatures/dto/create-envelope.dto.ts b/apps/api/src/signatures/dto/create-envelope.dto.ts index 7b4a89d..2c9cea2 100644 --- a/apps/api/src/signatures/dto/create-envelope.dto.ts +++ b/apps/api/src/signatures/dto/create-envelope.dto.ts @@ -43,9 +43,17 @@ export class CreateEnvelopeDto { @IsUUID("4", { each: true }) documentGenerationIds!: string[]; + /** + * Omitted/empty → `contratante` signers are derived automatically from + * the envelope's origin (`clientRegistrationId`/`resellerId`): the + * legal representative (if authorized) plus every sócio marked + * `will_sign` — see `EnvelopeService.deriveContratanteSigners`. Provide + * this explicitly to override that derivation entirely (e.g. a signer + * not on file, or an origin-less envelope). + */ + @IsOptional() @IsArray() - @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => SignerInputDto) - signers!: SignerInputDto[]; + signers?: SignerInputDto[]; } diff --git a/apps/api/src/signatures/envelope.service.ts b/apps/api/src/signatures/envelope.service.ts index 3af7d7f..d630b18 100644 --- a/apps/api/src/signatures/envelope.service.ts +++ b/apps/api/src/signatures/envelope.service.ts @@ -11,6 +11,10 @@ import { companiesRepo, documentsRepo, signaturesRepo, + clientRegistrationsRepo, + clientRegistrationPartnersRepo, + resellersRepo, + resellerPartnersRepo, type ActorType, type EnvelopeWithDetails, type SignatureEnvelope, @@ -33,6 +37,18 @@ export interface RequestContext { userAgent: string | null; } +interface ContratanteSignerDraft { + party: "contratante"; + personType: "pf" | "pj" | null; + fullName: string; + cpf: string | null; + email: string; + phone: string | null; + companyName: string | null; + cnpj: string | null; + roleTitle: string | null; +} + function resolveActorType(roleKey: string): ActorType { if (roleKey === "super_admin") return "SUPER_ADMIN"; if (roleKey === "admin") return "ADMIN"; @@ -119,6 +135,7 @@ export class EnvelopeService { }); } + const contratanteSigners = await this.deriveContratanteSigners(dto); const signers = [ { party: "contratada" as const, @@ -127,17 +144,7 @@ export class EnvelopeService { email: company.legal_rep_email, roleTitle: company.legal_rep_role, }, - ...dto.signers.map((s) => ({ - party: "contratante" as const, - personType: s.personType ?? null, - fullName: s.fullName, - cpf: s.cpf ?? null, - email: s.email, - phone: s.phone ?? null, - companyName: s.companyName ?? null, - cnpj: s.cnpj ?? null, - roleTitle: s.roleTitle ?? null, - })), + ...contratanteSigners, ]; const envelope = await signaturesRepo.createEnvelope( @@ -161,6 +168,157 @@ export class EnvelopeService { return envelope; } + /** + * `dto.signers` explícito (não vazio) sempre vence — override manual + * completo, mesmo comportamento de antes desta função existir. Vazio/ + * omitido: deriva automaticamente da origem do envelope (usuário pediu + * isso depois de completar o cadastro de sócios/representante legal em + * client_registrations e resellers) — representante legal autorizado + * (`*_rep_authorization_ack`) + cada sócio marcado `will_sign`, dedup + * por CPF (o representante pode também constar como sócio). PF de + * client_registrations não tem sócios/representante — o próprio titular + * é o único signatário possível. + */ + private async deriveContratanteSigners(dto: CreateEnvelopeDto): Promise { + if (dto.signers && dto.signers.length > 0) { + return dto.signers.map((s) => ({ + party: "contratante" as const, + personType: s.personType ?? null, + fullName: s.fullName, + cpf: s.cpf ?? null, + email: s.email, + phone: s.phone ?? null, + companyName: s.companyName ?? null, + cnpj: s.cnpj ?? null, + roleTitle: s.roleTitle ?? null, + })); + } + + if (dto.clientRegistrationId) { + const registration = await clientRegistrationsRepo.getClientRegistrationById(dto.clientRegistrationId); + if (!registration) throw new NotFoundException("Cadastro de cliente não encontrado"); + + if (registration.person_type === "pf") { + if (!registration.pf_full_name || !registration.pf_email) { + throw new BadRequestException( + "O cadastro de cliente não tem nome/e-mail completos para assinar — complete o cadastro ou informe os signatários manualmente.", + ); + } + return [ + { + party: "contratante" as const, + personType: "pf" as const, + fullName: registration.pf_full_name, + cpf: registration.pf_cpf, + email: registration.pf_email, + phone: registration.pf_mobile_phone, + companyName: null, + cnpj: null, + roleTitle: null, + }, + ]; + } + + const partners = await clientRegistrationPartnersRepo.listPartnersForRegistration(dto.clientRegistrationId); + return this.deriveFromRepAndPartners({ + companyName: registration.pj_company_name, + cnpj: registration.pj_cnpj, + repFullName: registration.pj_rep_full_name, + repCpf: registration.pj_rep_cpf, + repRole: registration.pj_rep_role, + repEmail: registration.pj_rep_email, + repMobilePhone: registration.pj_rep_mobile_phone, + repAuthorizationAck: registration.pj_rep_authorization_ack, + partners, + originLabel: "cadastro de cliente", + }); + } + + // `hasReseller` — a XOR de clientRegistrationId/resellerId já foi validada por quem chama. + const reseller = await resellersRepo.getResellerById(dto.resellerId!); + if (!reseller) throw new NotFoundException("Revenda não encontrada"); + const partners = await resellerPartnersRepo.listPartnersForReseller(dto.resellerId!); + return this.deriveFromRepAndPartners({ + companyName: reseller.name, + cnpj: reseller.document, + repFullName: reseller.rep_full_name, + repCpf: reseller.rep_cpf, + repRole: reseller.rep_role, + repEmail: reseller.rep_email, + repMobilePhone: reseller.rep_mobile_phone, + repAuthorizationAck: reseller.rep_authorization_ack, + partners, + originLabel: "revenda", + }); + } + + private deriveFromRepAndPartners(input: { + companyName: string | null; + cnpj: string | null; + repFullName: string | null; + repCpf: string | null; + repRole: string | null; + repEmail: string | null; + repMobilePhone: string | null; + repAuthorizationAck: boolean; + partners: Array<{ + name: string; + cpf: string | null; + qualification: string | null; + will_sign: boolean; + signature_email: string | null; + }>; + originLabel: string; + }): ContratanteSignerDraft[] { + const signers: ContratanteSignerDraft[] = []; + const seenCpfs = new Set(); + + if (input.repAuthorizationAck && input.repFullName && input.repEmail) { + signers.push({ + party: "contratante", + personType: "pf", + fullName: input.repFullName, + cpf: input.repCpf, + email: input.repEmail, + phone: input.repMobilePhone, + companyName: input.companyName, + cnpj: input.cnpj, + roleTitle: input.repRole, + }); + if (input.repCpf) seenCpfs.add(input.repCpf); + } + + for (const partner of input.partners) { + if (!partner.will_sign) continue; + if (partner.cpf && seenCpfs.has(partner.cpf)) continue; // já coberto pelo representante + if (!partner.signature_email) { + throw new BadRequestException( + `O sócio "${partner.name}" está marcado para assinar mas não tem e-mail de assinatura cadastrado — complete o cadastro (${input.originLabel}) ou informe os signatários manualmente.`, + ); + } + signers.push({ + party: "contratante", + personType: "pf", + fullName: partner.name, + cpf: partner.cpf, + email: partner.signature_email, + phone: null, + companyName: input.companyName, + cnpj: input.cnpj, + roleTitle: partner.qualification, + }); + if (partner.cpf) seenCpfs.add(partner.cpf); + } + + if (signers.length === 0) { + throw new BadRequestException( + `Nenhum sócio marcado para assinar nem representante legal autorizado no cadastro (${input.originLabel}) — marque quem assina no cadastro ou informe os signatários manualmente nesta requisição.`, + ); + } + + return signers; + } + /** eden.md §5.5: gera um token novo por signatário-alvo ainda não assinado (revoga implicitamente o anterior), 7 dias de validade, e-mail com o link. Envelope READY->SENT na primeira vez. */ async sendInvite(id: string, dto: SendInviteDto, user: EdenAuthenticatedUser): Promise { const envelope = await this.get(id, user); diff --git a/apps/api/test/signature-envelope-auto-signers.spec.ts b/apps/api/test/signature-envelope-auto-signers.spec.ts new file mode 100644 index 0000000..20c092c --- /dev/null +++ b/apps/api/test/signature-envelope-auto-signers.spec.ts @@ -0,0 +1,302 @@ +import { INestApplication } from "@nestjs/common"; +import { Readable } from "node:stream"; +import { PDFDocument } from "pdf-lib"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, qaName, type AuthedTokens } from "./setup.js"; + +/** + * `POST /v1/signature-envelopes` with `signers` omitted — contratante + * signers derived automatically from the envelope's origin + * (`clientRegistrationId`/`resellerId`): the legal representative (if + * `*_rep_authorization_ack`) plus every sócio marked `will_sign`, deduped + * by CPF. Mocks match `signature-envelope-flow.spec.ts` exactly (no real + * Chromium/MinIO/SMTP needed). + */ +const s3Store = new Map(); + +vi.mock("@eden/integrations", async () => { + const actual = await vi.importActual("@eden/integrations"); + return { + ...actual, + renderPdf: vi.fn(async () => { + const doc = await PDFDocument.create(); + doc.addPage(); + return Buffer.from(await doc.save()); + }), + uploadObject: vi.fn(async (key: string, buffer: Buffer) => { + s3Store.set(key, buffer); + }), + getObjectStream: vi.fn(async (key: string) => { + const buffer = s3Store.get(key); + if (!buffer) throw new Error(`qa mock S3: no object at ${key}`); + return Readable.from([buffer]); + }), + sendMail: vi.fn(async () => undefined), + }; +}); + +describe("signature envelopes — automatic contratante signer derivation", () => { + let app: INestApplication; + let admin: AuthedTokens; + let companyId: string; + let generationId: string; + + async function createGeneration(resellerId?: string, clientRegistrationId?: string) { + const template = await agent(app) + .post("/api/v1/documents/templates") + .set(...bearer(admin.accessToken)) + .send({ key: `qa_test_autosig_${Date.now()}_${Math.random().toString(36).slice(2)}`, name: qaName("Template AutoSigners"), docGroup: "revenda" }) + .expect(201); + const templateId = template.body.id; + await agent(app).post(`/api/v1/documents/templates/${templateId}/draft`).set(...bearer(admin.accessToken)).expect(201); + await agent(app) + .patch(`/api/v1/documents/templates/${templateId}/draft`) + .set(...bearer(admin.accessToken)) + .send({ editorContent: { type: "doc", plainText: "Contrato de teste." } }) + .expect(200); + await agent(app).post(`/api/v1/documents/templates/${templateId}/publish`).set(...bearer(admin.accessToken)).expect(201); + const generation = await agent(app) + .post("/api/v1/documents/generations") + .set(...bearer(admin.accessToken)) + .send({ templateId, resellerId, clientRegistrationId, context: {} }) + .expect(201); + return generation.body.id as string; + } + + beforeAll(async () => { + app = await createTestApp(); + admin = await loginSuperAdmin(app); + + const company = await agent(app) + .post("/api/v1/companies") + .set(...bearer(admin.accessToken)) + .send({ + companyName: qaName("Empresa AutoSigners"), + legalRepName: "Representante Legal QA", + legalRepCpf: "11144477735", + legalRepEmail: qaEmail("autosig-contratada"), + legalRepRole: "Diretor", + }) + .expect(201); + companyId = company.body.id; + }); + + afterAll(async () => { + await closeTestApp(app); + }); + + describe("origem client_registrations (PJ)", () => { + let registrationId: string; + + beforeAll(async () => { + const reg = await agent(app) + .post("/api/v1/client-registrations/direct") + .set(...bearer(admin.accessToken)) + .send({ personType: "pj", isPortability: false }) + .expect(201); + registrationId = reg.body.id; + generationId = await createGeneration(undefined, registrationId); + }); + + it("400 — sem representante autorizado e sem sócio marcado pra assinar, nada pra derivar", async () => { + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ companyId, clientRegistrationId: registrationId, documentGenerationIds: [generationId] }) + .expect(400); + expect(res.body.message).toMatch(/Nenhum sócio marcado|representante legal autorizado/); + }); + + it("400 — sócio marcado pra assinar mas sem e-mail de assinatura", async () => { + await agent(app) + .post(`/api/v1/client-registrations/${registrationId}/partners`) + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Sócio Sem Email"), willSign: true }) + .expect(201); + + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ companyId, clientRegistrationId: registrationId, documentGenerationIds: [generationId] }) + .expect(400); + expect(res.body.message).toContain("e-mail de assinatura"); + }); + + it("deriva o representante legal autorizado + o sócio marcado pra assinar, ignora o não-marcado", async () => { + // Cadastro próprio, dedicado — o `registrationId` do describe já + // acumulou, nos testes anteriores, um sócio `will_sign=true` sem + // e-mail (criado de propósito pra testar o 400) que continuaria + // presente e derrubaria este teste por um motivo alheio ao que ele + // verifica. + const reg = await agent(app) + .post("/api/v1/client-registrations/direct") + .set(...bearer(admin.accessToken)) + .send({ personType: "pj", isPortability: false }) + .expect(201); + const freshRegistrationId = reg.body.id; + const freshGenerationId = await createGeneration(undefined, freshRegistrationId); + + const repEmail = qaEmail("autosig-pj-rep"); + await agent(app) + .patch(`/api/v1/client-registrations/${freshRegistrationId}`) + .set(...bearer(admin.accessToken)) + .send({ + pjRepFullName: "Representante PJ QA", + pjRepRole: "Sócio-Administrador", + pjRepEmail: repEmail, + pjRepMobilePhone: "11988887777", + pjRepAuthorizationAck: true, + }) + .expect(200); + + const willSignEmail = qaEmail("autosig-pj-partner"); + await agent(app) + .post(`/api/v1/client-registrations/${freshRegistrationId}/partners`) + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Sócio Assina"), willSign: true, signatureEmail: willSignEmail, qualification: "Sócio" }) + .expect(201); + await agent(app) + .post(`/api/v1/client-registrations/${freshRegistrationId}/partners`) + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Sócio Não Assina") }) + .expect(201); + + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ companyId, clientRegistrationId: freshRegistrationId, documentGenerationIds: [freshGenerationId] }) + .expect(201); + + const contratantes = res.body.signers.filter((s: { party: string }) => s.party === "contratante"); + expect(contratantes).toHaveLength(2); // rep + o único sócio marcado pra assinar + const emails = contratantes.map((s: { email: string }) => s.email); + expect(emails).toContain(repEmail); + expect(emails).toContain(willSignEmail); + }); + }); + + describe("origem client_registrations (PF)", () => { + it("usa o próprio titular como signatário único", async () => { + const reg = await agent(app) + .post("/api/v1/client-registrations/direct") + .set(...bearer(admin.accessToken)) + .send({ personType: "pf", isPortability: false }) + .expect(201); + const registrationId = reg.body.id; + const titularEmail = qaEmail("autosig-pf-titular"); + await agent(app) + .patch(`/api/v1/client-registrations/${registrationId}`) + .set(...bearer(admin.accessToken)) + .send({ pfFullName: "Titular PF QA", pfEmail: titularEmail, pfMobilePhone: "11977776666" }) + .expect(200); + + const genId = await createGeneration(undefined, registrationId); + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ companyId, clientRegistrationId: registrationId, documentGenerationIds: [genId] }) + .expect(201); + + const contratantes = res.body.signers.filter((s: { party: string }) => s.party === "contratante"); + expect(contratantes).toHaveLength(1); + expect(contratantes[0].email).toBe(titularEmail); + expect(contratantes[0].full_name).toBe("Titular PF QA"); + }); + }); + + describe("origem resellers", () => { + let resellerId: string; + + beforeAll(async () => { + const reseller = await agent(app) + .post("/api/v1/resellers") + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Revenda AutoSigners") }) + .expect(201); + resellerId = reseller.body.id; + generationId = await createGeneration(resellerId); + }); + + afterAll(async () => { + await agent(app) + .patch(`/api/v1/resellers/${resellerId}`) + .set(...bearer(admin.accessToken)) + .send({ active: false }) + .expect(200); + }); + + it("dedup por CPF — sócio com o mesmo CPF do representante aparece só uma vez", async () => { + const repCpf = "52998224725"; + const repEmail = qaEmail("autosig-reseller-rep"); + await agent(app) + .patch(`/api/v1/resellers/${resellerId}`) + .set(...bearer(admin.accessToken)) + .send({ + repFullName: "Representante Revenda QA", + repCpf, + repRole: "Diretor", + repEmail, + repAuthorizationAck: true, + }) + .expect(200); + + // Mesmo CPF do representante — não deve virar um segundo signatário. + await agent(app) + .post(`/api/v1/resellers/${resellerId}/partners`) + .set(...bearer(admin.accessToken)) + .send({ name: "Representante Revenda QA", cpf: repCpf, willSign: true, signatureEmail: qaEmail("autosig-reseller-dup") }) + .expect(201); + // (`toHaveLength(2)` abaixo já prova o dedup — rep + o único outro sócio, nunca 3.) + + const otherEmail = qaEmail("autosig-reseller-other"); + await agent(app) + .post(`/api/v1/resellers/${resellerId}/partners`) + .set(...bearer(admin.accessToken)) + .send({ name: qaName("Outro Sócio Revenda"), cpf: "12345678909", willSign: true, signatureEmail: otherEmail }) + .expect(201); + + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ companyId, resellerId, documentGenerationIds: [generationId] }) + .expect(201); + + const contratantes = res.body.signers.filter((s: { party: string }) => s.party === "contratante"); + expect(contratantes).toHaveLength(2); // rep (dedup'd against the same-CPF partner) + the other partner + const emails = contratantes.map((s: { email: string }) => s.email); + expect(emails).toContain(repEmail); + expect(emails).toContain(otherEmail); + }); + }); + + it("signers explícito continua funcionando como override manual completo, mesmo com rep/sócios disponíveis", async () => { + const reg = await agent(app) + .post("/api/v1/client-registrations/direct") + .set(...bearer(admin.accessToken)) + .send({ personType: "pj", isPortability: false }) + .expect(201); + const registrationId = reg.body.id; + await agent(app) + .patch(`/api/v1/client-registrations/${registrationId}`) + .set(...bearer(admin.accessToken)) + .send({ pjRepFullName: "Rep Ignorado", pjRepEmail: qaEmail("autosig-override-ignored"), pjRepAuthorizationAck: true }) + .expect(200); + + const genId = await createGeneration(undefined, registrationId); + const manualEmail = qaEmail("autosig-override-manual"); + const res = await agent(app) + .post("/api/v1/signature-envelopes") + .set(...bearer(admin.accessToken)) + .send({ + companyId, + clientRegistrationId: registrationId, + documentGenerationIds: [genId], + signers: [{ fullName: "Signatário Manual", email: manualEmail }], + }) + .expect(201); + + const contratantes = res.body.signers.filter((s: { party: string }) => s.party === "contratante"); + expect(contratantes).toHaveLength(1); + expect(contratantes[0].email).toBe(manualEmail); + }); +});