Export Templates
Sponsor-level.docx master templates with fund-level overrides, inherited sponsor details, and a fixed placeholder vocabulary rendered by docxtemplater.
The three export kinds
| Kind | Name | Used for |
|---|---|---|
isl | Individual Side Letter | Per-LP side letter rendered at commitment time — the LP's own clauses plus MFN-elected clauses. |
ef | Election Form | MFN election ballot handed to an LP listing clauses they can elect into. |
msl | Master Side Letter | Fund-wide MSL: the union of all elected / eligible clauses across the fund. |
Template inheritance
Each of the three kinds has a sponsor-level master template plus an optional fund-level override. At export time Hugo resolves the template key with a simple precedence chain — no files are copied between rows:
- Fund-own override — if
funds.export_template_{kind}_r2_keyis set, use that. - Sponsor master — else if the fund's sponsor has
sponsors.export_template_{kind}_r2_keyset, use that. - Hardcoded fallback — else fall back to the built-in
generateSideLetterpath (no template, Hugo composes the document itself).
Implemented by resolveFundTemplateKey(db, fundId, kind) in src/services/sponsor-export-templates.ts. The resolver returns both the R2 key and the source ('fund' | 'sponsor' | 'none') so the UI can show an "Inherits from sponsor" caption when a fund has no override.
Storage layout
Migration 0063 added three nullable R2-key columns to both the sponsors and funds tables:
sponsors.export_template_isl_r2_key
sponsors.export_template_ef_r2_key
sponsors.export_template_msl_r2_key
funds.export_template_isl_r2_key
funds.export_template_ef_r2_key
funds.export_template_msl_r2_key
Each column holds an R2 object key (or NULL). Uploading a new template writes a fresh R2 object and points the column at it; the old object is left in place.
Sponsor details (per-field inheritance)
Migration 0065 added three sponsor-details columns to the sponsors table, mirrored on funds as optional overrides:
| Column | Meaning |
|---|---|
firm_name | Sponsor law-firm name. |
firm_address | Multi-line sponsor address. |
firm_country | Sponsor country. |
firm_* for stability, but the user-visible labels were renamed on 2026-04-13 to Sponsor details / Sponsor name / Sponsor address / Sponsor country since these describe the sponsor law firm, not Hugo's own firm.
Per-field COALESCE
resolveFundFirmDetails(db, fundId) resolves each of the three fields independently:
- If the fund has a non-null value for that field → source:
'fund'. - Else if the sponsor has a non-null value → source:
'sponsor'. - Else → source:
'none', value isnull.
Resolution is per-field, not per-row: a fund can override firm_address alone and still inherit firm_name and firm_country from the sponsor. Each field's source is tracked independently so the fund-level settings UI can show which values are inherited.
The resolved values are injected into the render data as the {firm_name}, {firm_address}, and {firm_country} placeholders below. {firm_address} preserves newlines via docxtemplater's linebreaks: true mode.
Settings UI
/sponsors/:id/export/settings
- Sponsor-details form (
firm_name,firm_address,firm_country) — these are the defaults inherited by every fund in the sponsor. - Master-template upload slots for each of
isl,ef,msl. - Supported-placeholders reference section rendered from the same canonical list.
/funds/:id/export/settings
- Sponsor-details form with "Inherits from sponsor" captions shown next to any empty override field.
- Override-template upload slots for each of
isl,ef,msl; empty slots display the resolved sponsor master. - Same supported-placeholders reference section.
Supported placeholders
The canonical list lives in src/services/document-export-template.ts as the exported SUPPORTED_PLACEHOLDERS and SUPPORTED_LOOPS arrays. Both settings pages render their reference section from that same helper, and the upload-time validator rejects any tag not on the list.
Plain tokens
| Token | Description |
|---|---|
{fund_name} | Name of the fund. |
{lp_name} | Investor display name (the investor's `name`). |
{lp_legal_name} | LP's full legal name as registered. |
{lp_short_name} | LP's short / 'attn:' name. Empty if not set. |
{lp_country} | LP's country code (ISO-2). Empty if not set. |
{lp_category} | Investor category label (e.g. "Pension fund"). |
{commitment_amount} | Commitment formatted with thousands separators. |
{commitment_currency} | Commitment currency code. |
{commitment_date} | LP admission date (YYYY-MM-DD), empty if not set. |
{today} | Today's date in long-form English (e.g. "11 April 2026"). |
{firm_name} | Sponsor name (per-field inheritance: fund override → sponsor default). |
{firm_address} | Multi-line sponsor address. Newlines preserved via `linebreaks: true`. |
{firm_country} | Sponsor country, separate from address. |
Loop tokens
| Loop | Description |
|---|---|
{#clauses}...{/clauses} | Loop over the selected clauses. Inside: {clause_name}, {text}, {index}. |
Inside a {#clauses}...{/clauses} block, the recognized loop-inner tags are {clause_name}, {text}, and {index}. Any other tag inside the loop body is treated as unknown by the validator.
Validation
validateTemplate(buffer) walks every {...} token in the uploaded .docx and buckets them into recognized, unknown, and loop sets. The upload UI shows the result so authors can see exactly which placeholders Hugo will fill and which are typos. Unknown tags don't block upload, but they will surface as docxtemplater render errors at export time — the renderer wraps those into a TemplateRenderError whose offendingTags list feeds back into the UI.
Rendering
At export time Hugo reads the resolved R2 object, hands the buffer to renderIslFromTemplate(buffer, data), and streams the resulting .docx back to the client. The render data is built by buildRenderData(ExportData) and contains exactly the fields enumerated in the placeholder table above — no dynamic expansion, no template-defined helpers. Rendering is pure JS (pizzip + docxtemplater), synchronous, and runs inside the Workers runtime with no native deps.
exportIslHandler falls back to the hardcoded generateSideLetter path. The hardcoded path does not use the placeholder vocabulary — it composes the document programmatically.