feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m34s

EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.

Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local:     CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
             multiline address wrap), uploads the flattened PDF to Documenso,
             and places ONLY the 6 page-3 signature fields. Documenso never
             re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.

Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.

- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
  ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
  keeps its label/required

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-25 02:48:11 +02:00
parent af05bb18dc
commit 0ca9b2c3b5
6 changed files with 431 additions and 40 deletions

View File

@@ -391,8 +391,13 @@ export async function createDocument(
return getDocument(envelopeId, portId); return getDocument(envelopeId, portId);
} }
// v1: existing path. Meta keys are accepted at the top level. // v1: existing path. Meta keys are accepted at the top level. We still send
return documensoFetch( // `document` (base64) for older Documenso servers that store it inline, but
// Documenso 2.x's v1-compat endpoint instead returns a presigned `uploadUrl`
// and expects the PDF bytes to be PUT there (the base64 is ignored). So when
// the create response carries an `uploadUrl`, upload the bytes to it — without
// this the document is created with NO content (signers see a blank PDF).
const raw = (await documensoFetch(
'/api/v1/documents', '/api/v1/documents',
{ {
method: 'POST', method: 'POST',
@@ -412,7 +417,39 @@ export async function createDocument(
}), }),
}, },
portId, portId,
).then(normalizeDocument); )) as Record<string, unknown>;
const uploadUrl = typeof raw.uploadUrl === 'string' ? raw.uploadUrl : null;
if (uploadUrl) {
const pdfBuffer = Buffer.from(pdfBase64, 'base64');
let putRes: Response;
try {
putRes = await fetchWithTimeout(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/pdf' },
body: pdfBuffer,
});
} catch (err) {
if (err instanceof FetchTimeoutError) {
throw new CodedError('DOCUMENSO_TIMEOUT', {
internalMessage: `v1 createDocument uploadUrl PUT timed out after ${err.timeoutMs}ms`,
});
}
throw err;
}
if (!putRes.ok) {
const errText = await putRes.text().catch(() => '');
logger.error(
{ status: putRes.status, err: errText, portId },
'Documenso v1 createDocument uploadUrl PUT failed - document has no content',
);
throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', {
internalMessage: `v1 createDocument uploadUrl PUT → ${putRes.status}: ${errText}`,
});
}
}
return normalizeDocument(raw);
} }
export async function generateDocumentFromTemplate( export async function generateDocumentFromTemplate(
@@ -1340,6 +1377,10 @@ export async function placeFields(
pageY: Math.round((f.pageY / 100) * dims.height), pageY: Math.round((f.pageY / 100) * dims.height),
pageWidth: Math.round((f.pageWidth / 100) * dims.width), pageWidth: Math.round((f.pageWidth / 100) * dims.width),
pageHeight: Math.round((f.pageHeight / 100) * dims.height), pageHeight: Math.round((f.pageHeight / 100) * dims.height),
// Pass fieldMeta through on v1 too (Documenso 2.x's v1-compat endpoint
// accepts it) so TEXT fields like "Place of Signing" keep their label /
// required / placeholder. Older v1 servers ignore unknown keys.
...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}),
}; };
// Retry transient failures so one flaky 5xx mid-loop doesn't leave // Retry transient failures so one flaky 5xx mid-loop doesn't leave
// the document with a partial field set. 3 attempts at 250 / 500 / // the document with a partial field set. 3 attempts at 250 / 500 /
@@ -1425,6 +1466,93 @@ export function computeDefaultSignatureLayout(
})); }));
} }
/**
* EOI page-3 signature-block layout — the six fields template 8 carries, so
* the in-app pathway (local pdf-lib fill + flatten → upload as a Documenso
* document) produces a signed EOI that matches the legacy template output
* exactly. Coordinates are percent of page, captured verbatim from template 8.
*
* Client (signer 1) gets Signature + Name + Place-of-Signing (TEXT) + Date.
* Developer (signer 2) gets Name + Signature. The approver (signer 3) carries
* no fields. `fieldMeta` is passed through to Documenso (v1 + v2) so the
* Place-of-Signing field keeps its label / required / placeholder.
*/
export function computeEoiSignatureLayout(
clientRecipientId: number | string,
developerRecipientId: number | string,
): DocumensoFieldPlacement[] {
return [
{
recipientId: clientRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 64.81957098456644,
pageWidth: 21.21662173851308,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
{
recipientId: clientRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 64.81957098456644,
pageWidth: 24.33234194973456,
pageHeight: 4.303685358613111,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: clientRecipientId,
type: 'TEXT',
pageNumber: 3,
pageX: 14.49704042881816,
pageY: 57.4932908677896,
pageWidth: 24.4807121661721,
pageHeight: 4.40865329418904,
fieldMeta: {
type: 'text',
label: 'Place of Signing',
readOnly: false,
required: true,
textAlign: 'left',
placeholder: 'Anguilla, AI',
characterLimit: 0,
},
},
{
recipientId: clientRecipientId,
type: 'DATE',
pageNumber: 3,
pageX: 39.79290246256028,
pageY: 57.4932908677896,
pageWidth: 21.06824925816024,
pageHeight: 4.40865329418904,
fieldMeta: { type: 'date', fontSize: 10, overflow: 'auto', textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'NAME',
pageNumber: 3,
pageX: 14.34911393977768,
pageY: 72.56877244919716,
pageWidth: 24.33234194973456,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'name', fontSize: 12, textAlign: 'left' },
},
{
recipientId: developerRecipientId,
type: 'SIGNATURE',
pageNumber: 3,
pageX: 39.64497370960451,
pageY: 72.56877244919716,
pageWidth: 21.21662173851308,
pageHeight: 3.988781551885322,
fieldMeta: { type: 'signature', fontSize: 18, overflow: 'auto' },
},
];
}
/** /**
* Void/cancel a Documenso document. * Void/cancel a Documenso document.
* *

View File

@@ -17,11 +17,14 @@ import { emitToRoom } from '@/lib/socket/server';
import { buildStoragePath } from '@/lib/minio'; import { buildStoragePath } from '@/lib/minio';
import { getStorageBackend } from '@/lib/storage'; import { getStorageBackend } from '@/lib/storage';
import { env } from '@/lib/env'; import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { getCountryName } from '@/lib/i18n/countries'; import { getCountryName } from '@/lib/i18n/countries';
import { import {
createDocument as documensoCreate, createDocument as documensoCreate,
sendDocument as documensoSend, sendDocument as documensoSend,
generateDocumentFromTemplate as documensoGenerateFromTemplate, generateDocumentFromTemplate as documensoGenerateFromTemplate,
placeFields as documensoPlaceFields,
computeEoiSignatureLayout,
} from '@/lib/services/documenso-client'; } from '@/lib/services/documenso-client';
import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload'; import { buildDocumensoPayload, getPortEoiSigners } from '@/lib/services/documenso-payload';
import { getPortDocumensoConfig } from '@/lib/services/port-config'; import { getPortDocumensoConfig } from '@/lib/services/port-config';
@@ -714,7 +717,15 @@ async function generateAndSignViaInApp(
} }
const pdfBase64 = Buffer.concat(chunks).toString('base64'); const pdfBase64 = Buffer.concat(chunks).toString('base64');
// Create Documenso document // Per-port Documenso config for the post-signing redirect + signing order
// (parity with the documenso-template pathway).
const docCfg = await getPortDocumensoConfig(portId);
// Create the Documenso document from the locally-filled + flattened PDF.
// Because the detail fields are flattened by pdf-lib (clean 12pt + multiline
// address wrapping), Documenso never re-renders them — it only collects
// signatures. This is what fixes the auto-sized/clipped detail text the
// Documenso template-fill pathway produced.
const documensoDoc = await documensoCreate( const documensoDoc = await documensoCreate(
template.name, template.name,
pdfBase64, pdfBase64,
@@ -724,10 +735,38 @@ async function generateAndSignViaInApp(
role: s.role, role: s.role,
signingOrder: s.signingOrder, signingOrder: s.signingOrder,
})), })),
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
); );
// Place the EOI page-3 signature block. The flattened PDF carries no
// Documenso fields, so place the six fields (client Signature/Name/
// Place-of-Signing/Date, developer Name/Signature) at template 8's
// coordinates, mapped by signing order (1 = client, 2 = developer; the
// approver signs no fields).
if (template.templateType === 'eoi') {
const byOrder = new Map(documensoDoc.recipients.map((r) => [r.signingOrder, r.id]));
const clientRecipientId = byOrder.get(1);
const developerRecipientId = byOrder.get(2);
if (clientRecipientId && developerRecipientId) {
await documensoPlaceFields(
documensoDoc.id,
computeEoiSignatureLayout(clientRecipientId, developerRecipientId),
portId,
);
} else {
logger.warn(
{ docId: documensoDoc.id, recipients: documensoDoc.recipients.length },
'EOI in-app pathway: could not resolve client/developer recipients for signature-field placement',
);
}
}
// Send document for signing // Send document for signing
await documensoSend(documensoDoc.id); await documensoSend(documensoDoc.id, portId);
// Update our document record with Documenso ID and status // Update our document record with Documenso ID and status
await db await db
@@ -740,6 +779,46 @@ async function generateAndSignViaInApp(
}) })
.where(eq(documents.id, documentRecord.id)); .where(eq(documents.id, documentRecord.id));
// Persist per-recipient signer rows so the EOI tab's signing-progress panel
// and the webhook handler (which matches by token / email) work — parity
// with the documenso-template pathway. Strip the `(was: …)` /
// `(placeholder)` suffixes EMAIL_REDIRECT_TO bakes into names.
if (documensoDoc.recipients.length > 0) {
await db.insert(documentSigners).values(
documensoDoc.recipients.map((r) => {
const cleanName = (r.name || r.email)
.replace(/\s*\(was:[^)]*\)/i, '')
.replace(/\s*\(placeholder\b[^)]*\)/i, '')
.trim();
const role =
r.role.toUpperCase() === 'SIGNER' && r.signingOrder === 1
? 'client'
: normalizeSignerRole(r.role);
return {
documentId: documentRecord.id,
signerName: cleanName || r.email,
signerEmail: r.email,
signerRole: role,
signingOrder: r.signingOrder,
status: 'pending' as const,
signingUrl: r.signingUrl ?? null,
embeddedUrl: r.embeddedUrl ?? null,
signingToken: r.token ?? null,
invitedAt: null,
};
}),
);
}
// Stamp the interest's EOI milestone so the Overview tab flips to
// "EOI sent / awaiting signatures" — parity with the template pathway.
if (context.interestId) {
await db
.update(interests)
.set({ eoiDocStatus: 'sent', dateEoiSent: new Date(), updatedAt: new Date() })
.where(eq(interests.id, context.interestId));
}
void createAuditLog({ void createAuditLog({
userId: meta.userId, userId: meta.userId,
portId, portId,
@@ -794,19 +873,125 @@ async function generateAndSignViaDocumensoTemplate(
// platform to one Documenso instance per CRM process. // platform to one Documenso instance per CRM process.
const docCfg = await getPortDocumensoConfig(portId); const docCfg = await getPortDocumensoConfig(portId);
// v2 prefillFields-by-ID emission requires a field-name → field-ID map // Pick which side of the yacht's stored dimensions ships to the PDF.
// populated by the admin "Sync from Documenso" button. Absent (or partial)
// map → payload skips prefillFields and v2 accepts the legacy formValues
// shape via backward compat.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
// Pick which side of the yacht's stored dimensions ships to Documenso.
// The drawer's toggle drives this; if the caller omitted it, default to // The drawer's toggle drives this; if the caller omitted it, default to
// whichever unit the rep originally typed in (yacht.lengthUnit). Legacy // whichever unit the rep originally typed in (yacht.lengthUnit). Legacy
// yachts without a unit column default to 'ft'. // yachts without a unit column default to 'ft'.
const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft'; const dimensionUnit: 'ft' | 'm' = options?.dimensionUnit ?? eoiContext.yacht?.lengthUnit ?? 'ft';
// Document title used by both fill methods + the documents row.
const docTitle = `Expression of Interest ${eoiContext.client.fullName}`;
let documensoDoc;
let localFileId: string | null = null;
if (docCfg.eoiFillMethod === 'local') {
// LOCAL-FILL (default): fill + flatten the source PDF ourselves (pdf-lib,
// fixed 12pt + multiline address wrapping), upload the flattened PDF to
// Documenso as a document, and place ONLY the page-3 signature fields.
// Documenso never renders the body text, so it can't auto-size/clip it —
// this is the fix for the oversized/clipped detail fields the Documenso
// template-fill produced. Still flows through Documenso for signing, so
// branded invites, embedded signing, webhooks, and emails are unchanged.
const pdfBytes = await generateEoiPdfFromTemplate(eoiContext, { dimensionUnit });
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
const fileId = crypto.randomUUID();
const storagePath = buildStoragePath(
port?.slug ?? portId,
'eoi',
context.interestId,
fileId,
'pdf',
);
{
const buffer = Buffer.from(pdfBytes);
const backend = await getStorageBackend();
await backend.put(storagePath, buffer, {
contentType: 'application/pdf',
sizeBytes: buffer.length,
});
}
const [fileRecord] = await db
.insert(files)
.values({
portId,
clientId: context.clientId ?? null,
filename: 'expression-of-interest.pdf',
originalName: 'Expression of Interest.pdf',
mimeType: 'application/pdf',
sizeBytes: String(pdfBytes.byteLength),
storagePath,
storageBucket: env.MINIO_BUCKET,
category: 'eoi',
uploadedBy: meta.userId,
})
.returning();
localFileId = fileRecord!.id;
const created = await documensoCreate(
docTitle,
Buffer.from(pdfBytes).toString('base64'),
[
{
name: eoiContext.client.fullName,
email: eoiContext.client.primaryEmail ?? '',
role: 'signer',
signingOrder: 1,
},
{
name: signers.developer.name,
email: signers.developer.email,
role: 'signer',
signingOrder: 2,
},
{
name: signers.approver.name,
email: signers.approver.email,
role: 'approver',
signingOrder: 3,
},
],
portId,
{
redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
},
);
// Place the six page-3 signature fields at template-8 coordinates, mapped
// by signing order (1 = client, 2 = developer; approver signs no fields).
const byOrder = new Map(created.recipients.map((r) => [r.signingOrder, r.id]));
const clientRid = byOrder.get(1);
const developerRid = byOrder.get(2);
if (clientRid && developerRid) {
await documensoPlaceFields(
created.id,
computeEoiSignatureLayout(clientRid, developerRid),
portId,
);
} else {
logger.warn(
{ docId: created.id, recipients: created.recipients.length },
'EOI local-fill: could not resolve client/developer recipients for field placement',
);
}
// v2 envelopes don't return signing URLs until distribute; v1 returns them
// on create. Distribute (suppressing Documenso's own emails via
// distributionMethod:NONE on v2 / DRAFT-stays-quiet on v1) only when
// they're missing, so document_signers.signing_url is populated for the
// branded "Send invitation" flow regardless of API version.
const needsDistribute = created.recipients.some((r) => !r.signingUrl);
documensoDoc = needsDistribute ? await documensoSend(created.id, portId) : created;
} else {
// DOCUMENSO TEMPLATE FILL (legacy fallback, eoi_fill_method='documenso'):
// Documenso fills the template's AcroForm fields from the payload. Note it
// auto-sizes/clips long values — kept only as a per-port escape hatch.
// v2 prefillFields-by-ID needs a field-name → field-ID map from the admin
// "Sync from Documenso" button; absent it, v2 ignores the legacy formValues.
const { getEoiFieldMap } = await import('@/lib/services/documenso-template-sync.service');
const fieldMap = await getEoiFieldMap(portId);
const payload = buildDocumensoPayload( const payload = buildDocumensoPayload(
eoiContext, eoiContext,
{ {
@@ -818,24 +1003,22 @@ async function generateAndSignViaDocumensoTemplate(
developerEmail: signers.developer.email, developerEmail: signers.developer.email,
approverName: signers.approver.name, approverName: signers.approver.name,
approverEmail: signers.approver.email, approverEmail: signers.approver.email,
// Prefer per-port post-signing redirect (typically marketing-site
// /sign/success on v2). Falls back to APP_URL on v1 / when unset.
redirectUrl: docCfg.redirectUrl ?? env.APP_URL, redirectUrl: docCfg.redirectUrl ?? env.APP_URL,
// v2-only signing-order enforcement. v1 instances ignore this key.
...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}), ...(docCfg.signingOrder ? { signingOrder: docCfg.signingOrder } : {}),
dimensionUnit, dimensionUnit,
}, },
fieldMap, fieldMap,
); );
documensoDoc = await documensoGenerateFromTemplate(
const documensoDoc = await documensoGenerateFromTemplate(
docCfg.eoiTemplateId, docCfg.eoiTemplateId,
payload as unknown as Record<string, unknown>, payload as unknown as Record<string, unknown>,
portId, portId,
); );
}
// Record a documents row referencing the Documenso document. No local file - // Record a documents row referencing the Documenso document. Local-fill
// Documenso owns the PDF and delivers signed copies via webhook (handled elsewhere). // attaches the flattened PDF we stored; template-fill has no local file
// (Documenso owns the PDF; signed copy arrives via webhook).
const [documentRecord] = await db const [documentRecord] = await db
.insert(documents) .insert(documents)
.values({ .values({
@@ -843,8 +1026,9 @@ async function generateAndSignViaDocumensoTemplate(
clientId: context.clientId ?? null, clientId: context.clientId ?? null,
interestId: context.interestId, interestId: context.interestId,
documentType: 'eoi', documentType: 'eoi',
title: payload.title, title: docTitle,
status: 'sent', status: 'sent',
fileId: localFileId,
documensoId: documensoDoc.id, documensoId: documensoDoc.id,
documensoNumericId: documensoDoc.numericId, documensoNumericId: documensoDoc.numericId,
isManualUpload: false, isManualUpload: false,

View File

@@ -49,6 +49,11 @@ export const SETTING_KEYS = {
// timing-safe comparison. // timing-safe comparison.
documensoWebhookSecret: 'documenso_webhook_secret', documensoWebhookSecret: 'documenso_webhook_secret',
eoiDefaultPathway: 'eoi_default_pathway', eoiDefaultPathway: 'eoi_default_pathway',
// EOI body-text fill method: 'local' (CRM fills + flattens the PDF, clean
// 12pt + multiline address wrap, Documenso signs only) vs 'documenso'
// (legacy: Documenso fills the template AcroForm fields and auto-sizes /
// clips them). Toggleable per-port in admin → Documenso.
eoiFillMethod: 'eoi_fill_method',
// Identity of the developer + approver that the template's static // Identity of the developer + approver that the template's static
// recipient slots get filled with. Old system hardcoded these // recipient slots get filled with. Old system hardcoded these
// (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys // (David Mizrahi, Abbie May @ portnimara.com) but multi-port deploys
@@ -316,6 +321,16 @@ export interface PortDocumensoConfig {
apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none'; apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none';
eoiTemplateId: number; eoiTemplateId: number;
defaultPathway: EoiPathway; defaultPathway: EoiPathway;
/**
* EOI body-text fill method:
* - 'local' : CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
* multiline address wrapping), then uploads the flattened PDF
* to Documenso for signature placement only. Renders cleanly.
* - 'documenso': legacy — Documenso fills the template's AcroForm fields via
* the template-generate API (auto-sizes the text → clips it).
* Toggleable per-port in admin → Documenso. Defaults to 'local'.
*/
eoiFillMethod: 'local' | 'documenso';
/** Documenso template recipient slot IDs (per-instance numeric). */ /** Documenso template recipient slot IDs (per-instance numeric). */
clientRecipientId: number; clientRecipientId: number;
developerRecipientId: number; developerRecipientId: number;
@@ -387,6 +402,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
developerRecipientId, developerRecipientId,
approvalRecipientId, approvalRecipientId,
defaultPathway, defaultPathway,
eoiFillMethod,
developerName, developerName,
developerEmail, developerEmail,
approverName, approverName,
@@ -411,6 +427,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId), readSetting<string | number>(SETTING_KEYS.documensoDeveloperRecipientId, portId),
readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId), readSetting<string | number>(SETTING_KEYS.documensoApprovalRecipientId, portId),
readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId), readSetting<EoiPathway>(SETTING_KEYS.eoiDefaultPathway, portId),
readSetting<'local' | 'documenso'>(SETTING_KEYS.eoiFillMethod, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId), readSetting<string>(SETTING_KEYS.documensoDeveloperName, portId),
readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId), readSetting<string>(SETTING_KEYS.documensoDeveloperEmail, portId),
readSetting<string>(SETTING_KEYS.documensoApproverName, portId), readSetting<string>(SETTING_KEYS.documensoApproverName, portId),
@@ -464,6 +481,9 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
approvalRecipientId: approvalRecipientId:
toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0, toIntOrNull(approvalRecipientId) ?? env.DOCUMENSO_APPROVAL_RECIPIENT_ID ?? 0,
defaultPathway: defaultPathway ?? 'documenso-template', defaultPathway: defaultPathway ?? 'documenso-template',
// Default to the local-fill method (clean render + address wrapping). Set
// to 'documenso' per-port to fall back to Documenso's template AcroForm fill.
eoiFillMethod: eoiFillMethod === 'documenso' ? 'documenso' : 'local',
developerName: developerName ?? '', developerName: developerName ?? '',
developerEmail: developerEmail ?? '', developerEmail: developerEmail ?? '',
approverName: approverName ?? '', approverName: approverName ?? '',

View File

@@ -204,6 +204,20 @@ export const REGISTRY: SettingEntry[] = [
scope: 'port', scope: 'port',
defaultValue: 'documenso-template', defaultValue: 'documenso-template',
}, },
{
key: 'eoi_fill_method',
section: 'documenso.templates',
label: 'EOI form fill method',
description:
"How the EOI's detail fields (name, address, yacht, berth) get filled in. Local fill (recommended) = the CRM fills + flattens the PDF itself at a fixed 12pt with multiline address wrapping, then sends it to Documenso for signatures only — text renders cleanly. Documenso template fill = Documenso fills the template's form fields and auto-sizes the text, which oversizes/clips long values. Both still go through Documenso for signing, branded emails, and embedded signing.",
type: 'radio',
options: [
{ value: 'local', label: 'Local fill — clean text + address wrapping (recommended)' },
{ value: 'documenso', label: 'Documenso template fill — legacy (may clip long values)' },
],
scope: 'port',
defaultValue: 'local',
},
{ {
key: 'eoi_send_mode', key: 'eoi_send_mode',
section: 'documenso.templates', section: 'documenso.templates',

View File

@@ -40,6 +40,7 @@ function configurePort(version: 'v1' | 'v2'): void {
apiVersion: version, apiVersion: version,
eoiTemplateId: 8, eoiTemplateId: 8,
defaultPathway: 'documenso-template', defaultPathway: 'documenso-template',
eoiFillMethod: 'local',
clientRecipientId: 192, clientRecipientId: 192,
developerRecipientId: 193, developerRecipientId: 193,
approvalRecipientId: 194, approvalRecipientId: 194,

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { computeEoiSignatureLayout } from '@/lib/services/documenso-client';
// The EOI moves from the Documenso *template* pathway (Documenso fills the
// AcroForm detail fields and auto-sizes/clips them) to the in-app pathway:
// we fill + flatten the PDF locally, upload it as a Documenso *document*, then
// place ONLY the page-3 signature fields. This layout must match template 8's
// six fields exactly (client: Signature/Name/Place-of-Signing/Date; developer:
// Name/Signature) so the signed EOI looks identical. Coords are percent of page.
describe('computeEoiSignatureLayout', () => {
const CLIENT = 101;
const DEV = 102;
const fields = computeEoiSignatureLayout(CLIENT, DEV);
it('produces exactly the 6 page-3 EOI signature fields', () => {
expect(fields).toHaveLength(6);
expect(fields.every((f) => f.pageNumber === 3)).toBe(true);
});
it('maps client recipient to Signature + Name + Place-of-Signing + Date', () => {
const client = fields.filter((f) => f.recipientId === CLIENT);
expect(client.map((f) => f.type).sort()).toEqual(['DATE', 'NAME', 'SIGNATURE', 'TEXT']);
});
it('maps developer recipient to Name + Signature only', () => {
const dev = fields.filter((f) => f.recipientId === DEV);
expect(dev.map((f) => f.type).sort()).toEqual(['NAME', 'SIGNATURE']);
});
it('carries the Place-of-Signing label + required so the signer is prompted', () => {
const place = fields.find((f) => f.recipientId === CLIENT && f.type === 'TEXT');
expect(place?.fieldMeta?.label).toBe('Place of Signing');
expect(place?.fieldMeta?.required).toBe(true);
});
it('positions fields at template-8 coordinates (page-3 signature block)', () => {
const sig = fields.find((f) => f.recipientId === CLIENT && f.type === 'SIGNATURE');
expect(sig?.pageX).toBeCloseTo(39.645, 2);
expect(sig?.pageY).toBeCloseTo(64.82, 1);
const devSig = fields.find((f) => f.recipientId === DEV && f.type === 'SIGNATURE');
expect(devSig?.pageY).toBeCloseTo(72.57, 1);
});
});