From 7200c31486ab8e4c56764618c82d1e0718beace8 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Fri, 24 Apr 2026 16:09:27 +0200 Subject: [PATCH] feat(eoi): add Documenso template payload builder --- src/lib/services/documenso-payload.ts | 120 ++++++++++ tests/unit/services/documenso-payload.test.ts | 210 ++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 src/lib/services/documenso-payload.ts create mode 100644 tests/unit/services/documenso-payload.test.ts diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts new file mode 100644 index 0000000..9235ed9 --- /dev/null +++ b/src/lib/services/documenso-payload.ts @@ -0,0 +1,120 @@ +import type { EoiContext } from '@/lib/services/eoi-context'; + +export interface DocumensoTemplatePayload { + title: string; + externalId: string; + meta: { + message: string; + subject: string; + redirectUrl: string; + distributionMethod: 'NONE' | 'EMAIL'; + }; + formValues: { + Name: string; + Email: string; + Address: string; + 'Yacht Name': string; + Length: string; + Width: string; + Draft: string; + 'Berth Number': string; + Lease_10: boolean; + Purchase: boolean; + }; + recipients: Array<{ + id: number; + name: string; + email: string; + role: 'SIGNER' | 'APPROVER'; + signingOrder: number; + }>; +} + +export interface DocumensoPayloadOptions { + /** `interestId` used to build `externalId` and Documenso referencing. */ + interestId: string; + /** Documenso recipient IDs — come from env vars. */ + clientRecipientId: number; + developerRecipientId: number; + approvalRecipientId: number; + /** Hardcoded developer + approver names/emails (legacy). */ + developerName?: string; + developerEmail?: string; + approverName?: string; + approverEmail?: string; + /** Redirect URL after signing. Defaults to the app URL. */ + redirectUrl?: string; +} + +const DEFAULT_DEVELOPER_NAME = 'David Mizrahi'; +const DEFAULT_DEVELOPER_EMAIL = 'dm@portnimara.com'; +const DEFAULT_APPROVER_NAME = 'Abbie May'; +const DEFAULT_APPROVER_EMAIL = 'sales@portnimara.com'; +const DEFAULT_REDIRECT_URL = 'https://portnimara.com'; + +function formatAddress(address: EoiContext['client']['address']): string { + if (!address) return ''; + return [address.street, address.city, address.country].filter(Boolean).join(', '); +} + +function buildMessage(context: EoiContext): string { + const greeting = `Dear ${context.client.fullName},`; + const body = `Thank you for your interest in a berth at ${context.port.name}. Please click the link above to sign your LOI.`; + const onBehalf = + context.owner.type === 'company' && context.company + ? `\n\nOn behalf of ${context.company.legalName ?? context.company.name} (representing the yacht's owner).` + : ''; + const footer = `\n\nBest Regards,\n${context.port.name} Team`; + return `${greeting}\n\n${body}${onBehalf}${footer}`; +} + +export function buildDocumensoPayload( + context: EoiContext, + options: DocumensoPayloadOptions, +): DocumensoTemplatePayload { + return { + title: `${context.client.fullName}-EOI-NDA`, + externalId: `loi-${options.interestId}`, + meta: { + message: buildMessage(context), + subject: 'Your LOI is ready to be signed', + redirectUrl: options.redirectUrl ?? DEFAULT_REDIRECT_URL, + distributionMethod: 'NONE', + }, + formValues: { + Name: context.client.fullName, + Email: context.client.primaryEmail ?? '', + Address: formatAddress(context.client.address), + 'Yacht Name': context.yacht.name, + Length: context.yacht.lengthFt ?? '', + Width: context.yacht.widthFt ?? '', + Draft: context.yacht.draftFt ?? '', + 'Berth Number': context.berth.mooringNumber, + Lease_10: false, + Purchase: true, + }, + recipients: [ + { + id: options.clientRecipientId, + name: context.client.fullName, + email: context.client.primaryEmail ?? '', + role: 'SIGNER', + signingOrder: 1, + }, + { + id: options.developerRecipientId, + name: options.developerName ?? DEFAULT_DEVELOPER_NAME, + email: options.developerEmail ?? DEFAULT_DEVELOPER_EMAIL, + role: 'SIGNER', + signingOrder: 2, + }, + { + id: options.approvalRecipientId, + name: options.approverName ?? DEFAULT_APPROVER_NAME, + email: options.approverEmail ?? DEFAULT_APPROVER_EMAIL, + role: 'APPROVER', + signingOrder: 3, + }, + ], + }; +} diff --git a/tests/unit/services/documenso-payload.test.ts b/tests/unit/services/documenso-payload.test.ts new file mode 100644 index 0000000..2f05b5e --- /dev/null +++ b/tests/unit/services/documenso-payload.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect } from 'vitest'; + +import { buildDocumensoPayload } from '@/lib/services/documenso-payload'; +import type { EoiContext } from '@/lib/services/eoi-context'; + +function makeContext(overrides?: Partial): EoiContext { + return { + client: { + fullName: 'Alice Smith', + nationality: 'US', + primaryEmail: 'alice@example.com', + primaryPhone: '+1-555-0100', + address: { street: '123 Main St', city: 'Austin', country: 'USA' }, + }, + yacht: { + name: 'Sea Breeze', + lengthFt: '45', + widthFt: '14', + draftFt: '6', + lengthM: null, + widthM: null, + draftM: null, + hullNumber: 'ABC-123', + flag: 'US', + yearBuilt: 2020, + }, + company: null, + owner: { type: 'client', name: 'Alice Smith' }, + berth: { + mooringNumber: 'A-12', + area: 'North Dock', + lengthFt: '50', + price: '1200', + priceCurrency: 'USD', + tenureType: 'permanent', + }, + interest: { + stage: 'open', + leadCategory: null, + dateFirstContact: null, + notes: null, + }, + port: { + name: 'Port Nimara', + defaultCurrency: 'USD', + }, + date: { today: '2026-04-23', year: '2026' }, + ...overrides, + }; +} + +const OPTIONS = { + interestId: 'int-123', + clientRecipientId: 192, + developerRecipientId: 193, + approvalRecipientId: 194, +}; + +describe('buildDocumensoPayload', () => { + it('builds title as "{fullName}-EOI-NDA"', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.title).toBe('Alice Smith-EOI-NDA'); + }); + + it('builds externalId as "loi-{interestId}"', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.externalId).toBe('loi-int-123'); + }); + + it('formats formValues with all EoiContext fields', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.formValues).toEqual({ + Name: 'Alice Smith', + Email: 'alice@example.com', + Address: '123 Main St, Austin, USA', + 'Yacht Name': 'Sea Breeze', + Length: '45', + Width: '14', + Draft: '6', + 'Berth Number': 'A-12', + Lease_10: false, + Purchase: true, + }); + }); + + it('defaults missing primaryEmail to empty string', () => { + const ctx = makeContext({ client: { ...makeContext().client, primaryEmail: null } }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.formValues.Email).toBe(''); + expect(payload.recipients[0]!.email).toBe(''); + }); + + it('defaults missing yacht dimensions to empty strings', () => { + const ctx = makeContext({ + yacht: { ...makeContext().yacht, lengthFt: null, widthFt: null, draftFt: null }, + }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.formValues.Length).toBe(''); + expect(payload.formValues.Width).toBe(''); + expect(payload.formValues.Draft).toBe(''); + }); + + it('formats empty address when client has no address', () => { + const ctx = makeContext({ client: { ...makeContext().client, address: null } }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.formValues.Address).toBe(''); + }); + + it('skips null parts in address', () => { + const ctx = makeContext({ + client: { + ...makeContext().client, + address: { street: '', city: 'Austin', country: 'USA' }, + }, + }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.formValues.Address).toBe('Austin, USA'); + }); + + it('sets Lease_10=false and Purchase=true (hardcoded)', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.formValues.Lease_10).toBe(false); + expect(payload.formValues.Purchase).toBe(true); + }); + + it('includes client, developer, and approver recipients in signing order', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.recipients).toHaveLength(3); + expect(payload.recipients[0]).toEqual({ + id: 192, + name: 'Alice Smith', + email: 'alice@example.com', + role: 'SIGNER', + signingOrder: 1, + }); + expect(payload.recipients[1]).toEqual({ + id: 193, + name: 'David Mizrahi', + email: 'dm@portnimara.com', + role: 'SIGNER', + signingOrder: 2, + }); + expect(payload.recipients[2]).toEqual({ + id: 194, + name: 'Abbie May', + email: 'sales@portnimara.com', + role: 'APPROVER', + signingOrder: 3, + }); + }); + + it('allows overriding developer/approver recipient names', () => { + const payload = buildDocumensoPayload(makeContext(), { + ...OPTIONS, + developerName: 'Custom Dev', + developerEmail: 'dev@custom.com', + approverName: 'Custom Approver', + approverEmail: 'approve@custom.com', + }); + expect(payload.recipients[1]!.name).toBe('Custom Dev'); + expect(payload.recipients[1]!.email).toBe('dev@custom.com'); + expect(payload.recipients[2]!.name).toBe('Custom Approver'); + expect(payload.recipients[2]!.email).toBe('approve@custom.com'); + }); + + it('builds message with port name and greeting', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.meta.message).toContain('Dear Alice Smith'); + expect(payload.meta.message).toContain('Port Nimara'); + expect(payload.meta.message).toContain('Best Regards'); + // No company on-behalf block for client-owned yachts + expect(payload.meta.message).not.toContain('On behalf of'); + }); + + it('adds company on-behalf block for company-owned yachts', () => { + const ctx = makeContext({ + company: { + name: 'Aegean Holdings', + legalName: 'Aegean Holdings SA', + taxId: null, + billingAddress: null, + }, + owner: { type: 'company', name: 'Aegean Holdings', legalName: 'Aegean Holdings SA' }, + }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.meta.message).toContain('On behalf of Aegean Holdings SA'); + }); + + it('uses company name when legalName is missing in on-behalf block', () => { + const ctx = makeContext({ + company: { name: 'Blue Seas', legalName: null, taxId: null, billingAddress: null }, + owner: { type: 'company', name: 'Blue Seas' }, + }); + const payload = buildDocumensoPayload(ctx, OPTIONS); + expect(payload.meta.message).toContain('On behalf of Blue Seas'); + }); + + it('uses default redirect URL when not provided', () => { + const payload = buildDocumensoPayload(makeContext(), OPTIONS); + expect(payload.meta.redirectUrl).toBe('https://portnimara.com'); + }); + + it('uses custom redirect URL when provided', () => { + const payload = buildDocumensoPayload(makeContext(), { + ...OPTIONS, + redirectUrl: 'https://custom.example.com', + }); + expect(payload.meta.redirectUrl).toBe('https://custom.example.com'); + }); +});