SSS DocGen — Usage & Customization Guide
How to use DocGen to generate documents, and how to customize it — add or edit templates, add or change datasets, edit the HTML, change labels, and push the changes to Dataverse. This is the day-to-day companion to the deep-dive docs:
plugin-registration.md— wiring thesss_GenerateDocumentcustom API to the plugindeployment-runbook.md— standing up a new Azure/Dataverse environment (read before deploying)rebranding.md— renaming the project / swapping brand colors & logo../templates/README.md— the template catalog &manifest.jsonschema
1. Mental model — how a document is produced
A template is configuration data, not code. Everything a document needs lives in a
template folder under templates/<group>/<code>/ and is seeded into two Dataverse
tables (sss_documenttemplate + child sss_templatedataset). At generation time the API
reads those rows, runs the queries, shapes the data, renders HTML, and prints a PDF.
templates/<group>/<code>/ → npm run seed → Dataverse
manifest.json sss_documenttemplate (1 row)
datasets/*.xml (FetchXML) sss_templatedataset (N rows)
template.html (Handlebars)
sample.json (preview data)
Runtime (per request):
sss_GenerateDocument(Code, EntityId, [Attach], [Output]) ← MDA / Power Automate / Code App / Power Pages
└─► plugin (thin proxy, Entra token) ─► POST /render {code, entityId, attach?, output?}
1. TemplateStore read sss_documenttemplate by sss_code
2. DatasetRunner run each FetchXML (@entityId@ → the record id)
3. TreeBuilder nest child rows under parents (sss_parentkeyfield)
4. Enricher add totals, aging, billName, labels, … (business logic)
5. Handlebars template.html + tree → HTML
6. PdfRenderer HTML → PDF (Playwright Chromium)
7. Delivery annotation on the record + draft email
◄─ { fileBase64, fileName, annotationId, content, warnings }
Output=data stops after step 4 (no Handlebars, no browser); Output=html stops after step 5.
The golden rule: business logic lives in the Enricher (src/SssDocGen.Api/Services/Enricher.cs),
never in templates. Templates only lay out values the tree already contains.
Templates/datasets are config data and must be changed in the repo, then seeded — never
hand-edited in production except as a hotfix that is then committed back.
2. Using DocGen — generating a document
The only public entry point is the Dataverse custom API sss_GenerateDocument. Two inputs are
required; Attach and Output are optional.
| Direction | Parameter | Type | Notes |
|---|---|---|---|
| In | Code |
String | Template code (the folder name, e.g. account-statement-openchecks) |
| In | EntityId |
String | Target record id (GUID) — the record the document is generated for |
| In | Attach |
Boolean | Optional, default false. When true the PDF is also persisted as an annotation on the record. Left false, nothing is stored — so interactive generation and preview accrue no Dataverse storage. |
| In | Output |
String | Optional, default pdf. Where the pipeline stops: pdf, html or data — see below. |
| Out | FileBase64 |
String | The PDF, base64-encoded. Null unless Output is pdf. |
| Out | FileName |
String | Suggested file name — <pattern>.pdf, <pattern>.html, or <code>.json depending on Output |
| Out | AnnotationId |
String | Id of the note the PDF was attached to. Null unless Attach was true. |
| Out | Content |
String | Rendered HTML (Output=html) or the enriched data tree as JSON (Output=data). Null when Output is pdf. |
| Out | Warnings |
String | Newline-separated non-fatal problems found while generating, or null when there were none. A response with warnings still produced a document — see below. |
Output modes
| Value | Stops after | Returns | Use it for |
|---|---|---|---|
pdf (default) |
the whole pipeline | FileBase64 |
normal document generation |
html |
the Handlebars body render | Content = HTML |
iterating on layout — no browser is started, so this skips both the PDF render and the ~10-20 s cold start |
data |
enrichment | Content = the enriched tree as JSON |
discovering what fields a template can actually read |
data mode renders no Handlebars at all — neither the body nor the file-name pattern — so it works
on a template whose template.html is still a placeholder. That is the point: it is how you find
out what data you have before writing the template. It is also the only way to see the
enrichment fields (today, todayLong, totals, aging, billName, clientNumber,
paymentTermsText, flags, billAddress), which are computed by the enrichers and appear in
no FetchXML.
remitis not one of them, and no longer a binding.account-statement-openchecksused to referenceremit.aba,remit.checkPayee,remit.onlineUrland friends, but nothing in the engine produces them — so the PAYMENT & REMITTANCE block rendered blank. The client's own remittance details are now literal text in the template. Per-client remittance configuration remains on the roadmap.
Which enricher applies
Two enrichers ship, and which values you get depends on both:
| Enricher | Applies to | Provides |
|---|---|---|
CommonEnricher |
every template | today, todayLong — at the tree root |
StatementEnricher |
statement-family templates | totals, aging, flags, billName, billAddress, clientNumber, amountDueLabel, creditBalanceMessage, paymentTermsText — on each root-dataset row |
Two things follow, and both bite template authors:
- Scope.
StatementEnricherwrites per row, not at the tree root. A template must therefore be inside{{#each <rootDataset>}}to see those values — referencing{{billName}}at root renders empty.CommonEnricher's two values are the opposite: they are root-level, so from inside an{{#each}}you need{{../todayLong}}. - Selection.
StatementEnrichermatches on the template's declared family (sss_family), falling back to the historical rule of "the code contains statement" while that column is not yet populated. So a non-statement template getsCommonEnricheronly.
today/todayLong deliberately live in CommonEnricher rather than behind a family gate, because
every practice-pro fileNamePattern references {{today}} — a value the file name depends on
cannot be optional.
A template that resolves no enricher now says so. The render still succeeds, but the response carries a warning naming the template (see Warnings below). Combined with
npm run lint-template, that replaces what used to be a silent blank.
Use Output=data to see exactly what a given code really resolves.
Attach is only valid with Output=pdf; combining it with html or data returns 400, since
there would be no PDF to attach. An unrecognised Output also returns 400, listing the valid
values.
Warnings
The engine's dangerous failure mode is not an exception — it is a document that renders
successfully but wrongly, which nobody notices. Warnings surfaces the cases where that can
happen. A warning never fails the request; it says you got a document, and here is why it may be
wrong.
| Warning | What happened | Why it matters |
|---|---|---|
| Dataset hit the row cap | A FetchXML query returned the 50,000-row maximum | The data is truncated — the document is incomplete, silently |
| Parent key could not be resolved | A child dataset's parentKeyField matched nothing on the parent |
All child rows were attached to every parent row, so rows are duplicated. The output looks plausible and is wrong |
| No enricher applies | The template resolved zero enrichers | Computed values are absent, so any placeholder referencing them renders empty |
| Draft email failed | The note was created but the draft email was not | The document is stored; only the email is missing |
Worth wiring into a Power Automate flow: branch on Warnings being non-empty rather than assuming
that a 200 means the document is correct. In particular, the first two are indistinguishable from a
healthy result by inspecting the PDF alone.
A null
EmailIdis not a warning by itself — it also means "draft email disabled", which is the default and normal case. Only the explicit warning distinguishes a failure from the default.
Disclosure note.
datareturns everything the datasets selected, including fields the template deliberately omits — a wider surface thansss_GenerateDocumentas equivalent to read access over the tables its templates query, restrict who can invoke it, and do not expose Data mode on an end-user button. The security & compliance review (available on request) covers this in full.
From Power Automate
Add a Perform an unbound action step → action sss_GenerateDocument → set Code and
EntityId. Use FileBase64 to attach/email, or set Attach to true to have the API store the
note itself. Setting Output to data gives you the record's data as JSON with no document at
all, if a flow needs the shaped data rather than a file.
From a model-driven app / Code App / Power Pages
Call the same unbound action via the Web API:
POST {env-url}/api/data/v9.2/sss_GenerateDocument
Content-Type: application/json
{ "Code": "account-statement-openchecks", "EntityId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301" }
Response contains FileBase64, FileName, AnnotationId, Content, Warnings. A Code that
doesn't exist — or that names a deactivated template — returns 404 "Template not found";
a missing/blank Code or empty EntityId returns 400. First call after idle
cold-starts ~10-20 s (the container scales to zero) — though Output=html and
Output=data avoid the PDF render, so they are far faster once warm.
The runtime /render contract (internal)
The plugin forwards to POST /render on the container app with an Entra client-credentials
token. You normally never call this directly — go through sss_GenerateDocument — but it's
the same contract with camelCase names: { code, entityId, attach?, output? } in /
{ fileBase64, fileName, annotationId, content, warnings } out, and there is an anonymous
GET /healthz for probes. See src/SssDocGen.Api/Program.cs.
The no-code way to edit templates: the config page
Non-developers edit templates through the Document Templates generative page in the
Mission Control model-driven app — master-detail CRUD over sss_documenttemplate and
sss_templatedataset (code, name, target entity, file-name pattern, HTML body, per-dataset
FetchXML), bilingual EN/PT. See ../genpages/docgen-template-config/README.md.
Prefer the repo + seed flow for anything you want version-controlled; the page is for quick
edits and hotfixes (commit those back).
3. Local development setup
Prereqs: Node 18+ and the .NET 8 SDK.
npm ci # installs handlebars + dotenv (see package.json)
dotnet build sss-docgen.sln # builds the engine + plugin
dotnet test # 100+ unit/integration tests
To seed or capture data you need Dataverse credentials in a .env.<env> file at the repo
root (git-ignored). --env dev loads .env.dev, --env tst loads .env.tst, etc.:
# .env.dev
DATAVERSE_URL=https://your-org.crm.dynamics.com
TENANT_ID=<entra-tenant-guid>
CLIENT_ID=<app-registration-client-id>
CLIENT_SECRET=<client-secret>
The five npm scripts (package.json):
| Command | What it does |
|---|---|
npm run preview <group/code> |
Render template.html against sample.json locally → HTML (no Dataverse) |
npm run lint-template <code> |
Check a template's bindings against its sample data; -- --all for every template |
npm run test:tools |
Unit tests for the Node tooling (node:test, no extra dependencies) |
npm run seed <code> |
Idempotent upsert of a template folder into Dataverse |
npm run capture-sample <code> |
Capture the enriched data tree for one record → sample.json (no Dataverse writes; synthetic-data environments only) |
npm run convert-legacy |
Regenerate template folders from source-material/ (legacy migration only) |
npm run rebrand |
Config-driven rename of the whole repo (see rebranding.md) |
4. Anatomy of a template folder
templates/practice-pro-365/account-statement-openchecks/
manifest.json # code, target entity, file-name pattern, dataset graph
datasets/
ts_invoice.xml # root dataset FetchXML (order 0)
ts_invoiceitemsummary.xml # child datasets (order 1..n)
account.xml
openinvoices.xml
openchecks.xml
template.html # Handlebars → HTML (the visual layout)
sample.json # representative tree for `npm run preview`
sample-credit.json # (optional) extra preview scenario, e.g. credit balance
logo.png # (optional) brand asset; wordmarks are text by default
Templates are grouped by product: templates/practice-pro-365/… and templates/unit4-psa/….
The code is the leaf folder name and is what callers pass as Code.
manifest.json
Full field reference is in ../templates/README.md. The shape:
{
"code": "account-statement-openchecks", // = folder name = the API Code
"name": "Account Statement - Standalone (...)", // sss_name (display)
"targetEntity": "ts_invoice", // entity EntityId points at
"fileNamePattern": "Statement_{{ts_invoice.0.ts_accountid.name}}_{{today}}",
"datasets": [
{ "name": "ts_invoice", "order": 0, "parentDataset": null, "parentKeyField": null,
"fetchXmlFile": "datasets/ts_invoice.xml" },
{ "name": "account", "order": 2, "parentDataset": "ts_invoice",
"parentKeyField": "ts_accountid.accountid", "fetchXmlFile": "datasets/account.xml" }
// …
]
}
- Root dataset (
parentDataset: null) runs first; its rows become the top of the tree ({{#each ts_invoice}}). - Child datasets are nested under their parent by
parentKeyField(the child-side key the TreeBuilder matches against the parent).ordercontrols execution order — roots before children so a parent always exists when its child attaches. fileNamePatternis itself rendered with Handlebars against the final tree, then sanitized and given a.pdfextension (DocumentGenerator.SanitizeFileName).
5. Adding a new template
Create the folder under the right product group:
templates/practice-pro-365/<new-code>/(and adatasets/subfolder).Write
manifest.json— setcode(= folder name),name,targetEntity, afileNamePattern, and thedatasets[]graph. If your datasets match an existing set, copy them from a sibling template (see the Dataset dedupe analysis intemplates/README.md— the 12 legacy templates collapse into 3 unique dataset sets).Add the FetchXML files in
datasets/(see §6). Use@entityId@wherever the query filters on the target record id.Capture sample data first, before writing the template — that way you know what fields you actually have:
npm run capture-sample <new-code> -- --entity-id <guid> --env devThis calls the API with
Output=data, so the capture is the enriched tree exactly as the engine builds it —totals,aging,billName,clientNumber,paymentTermsText,flagsand the rest included. Nothing needs hand-adding. It works even thoughtemplate.htmldoes not exist yet, becausedatamode renders no Handlebars.Synthetic data only.
sample.jsonis committed to git, so capture from an environment whose data is synthetic. The tool refuses to run against production and warns for anything not known to be synthetic.Write
template.html(see §7) against the captured data.Preview locally until it looks right:
npm run preview <new-code> # renders sample.json → tools/.preview/<code>.html npm run preview <new-code> -- --data sample-credit.json --openA bare code,
<group>/<code>, andtemplates/<group>/<code>all work — forpreview,seedandcapture-samplealike.To iterate against live data without waiting for a PDF, use
Output=html(the Document Templates config page exposes this as a preview mode).Lint it — this is what catches the mistakes preview hides, because Handlebars renders an unresolved binding as an empty string rather than failing:
npm run lint-template <new-code> npm run lint-template -- --all # the whole catalogIt reports three kinds of finding, and the distinction is the point:
Kind Meaning errora path in an output position ( {{x}}, or a helper argument) that does not existoka path used only as a block condition ( {{#if x}}) — absence is the question being asked, not a defectunknowna path inside {{#each}}over a collection that is empty in the sample, so it cannot be checkedA key that exists but is
nullis fine; only a missing key is an error. That matters because the enricher deliberately writes nulls (creditBalanceMessage), and Dataverse omits empty attributes entirely rather than nulling them.A structural check also runs, and it needs no sample data at all — so it works on a template that has never been captured. It cross-references every
{{#each}}subject against the manifest and catches two mistakes that are otherwise invisible: iterating a child dataset at root scope (where it is always empty), and iterating a dataset the manifest does not declare.Dry-run the seed, then apply:
npm run seed <new-code> -- --env dev --dry-run # shows planned creates/updates npm run seed <new-code> -- --env dev # applies; run twice → 2nd = "no changes"If the template needs computed values that don't yet exist in the tree, add them to the Enricher (§8) — do not compute them in the template.
Seeding is idempotent and driven by the sss_code alternate key: the template row is
upserted, dataset children are matched by sss_name (changed → PATCH, new → POST, removed
from the manifest → deleted as orphans). A second run with no local changes writes nothing.
6. Adding & changing datasets
A dataset is a FetchXML query stored as datasets/<name>.xml and listed in the manifest.
Rules
@entityId@placeholder — put it as the filter value wherever the query targets the recordEntityIdpoints at. At runtime the DatasetRunner replaces every@entityId@with the real GUID. Legacy queries used the zero-GUID{00000000-…-000000000000}; the converter swapped those for@entityId@, and any new query must do the same.<condition attribute="ts_invoiceid" operator="eq" value="@entityId@" />- Paging is automatic — 5000-row pages, 50k hard cap; do not add
page/countyourself. - Parent linkage — a child dataset declares
parentDataset(whose rows it nests under) andparentKeyField(the child-side key). The TreeBuilder matches the child key to a parent attribute (exact, then by last dotted segment, then by prefix). Aliased dotted keys likets_accountid.accountidare expanded into nested objects, soparentKeyField: "ts_accountid.accountid"links a child to the parent's account lookup. - Keep queries logic-light — select the columns the template/enricher need; don't try to do sums or grouping in FetchXML that the Enricher/Handlebars helpers already do.
To add a dataset to a template
- Add
datasets/<name>.xml. - Add an entry to
manifest.json→datasets[]withname,order,parentDataset,parentKeyField,fetchXmlFile. Ensure roots have lowerorderthan their children. npm run preview …(updatesample.jsonto include the new dataset's rows) →npm run seed … --dry-run→ seed.
To change a dataset
Edit the .xml. On the next seed, the changed FetchXML is PATCHed onto the existing
sss_templatedataset row (matched by sss_name). Renaming a dataset = delete-old +
create-new (the old name becomes an orphan and is removed) — update every parentDataset
reference and the template.html that iterates it.
7. Editing the HTML template
template.html is a Handlebars document rendered by
src/SssDocGen.Api/Services/HandlebarsRenderer.cs. The local preview tool mirrors those
helpers exactly (tools/preview.mjs), so what you see in preview is what the API renders.
The data available to the template
The root context is the tree: the root dataset name (e.g. ts_invoice) is an array of
rows, plus everything the Enricher added. Inside {{#each ts_invoice}} each row also carries
its nested child datasets and the enrichment fields:
| Path | Source | Example |
|---|---|---|
{{#each ts_invoice}} |
root dataset rows | one document per invoice |
account, openinvoices, openchecks, … |
nested child datasets | {{#each account}} |
billName, billAddress[], clientNumber |
Enricher (bill-to resolution) | {{billName}} |
totals.outstanding / .unappliedPayments / .amountDue |
Enricher | {{currency totals.amountDue}} |
aging.current / .over30 / .over60 / .over90 / .over180 / .outstanding |
Enricher | {{currency aging.over90}} |
flags.isCreditBalance |
Enricher | {{#if flags.isCreditBalance}} |
amountDueLabel, creditBalanceMessage, paymentTermsText |
Enricher (labels) | see §9 |
today (yyyy-MM-dd), todayLong (MMMM d, yyyy) |
Enricher (tree root) | {{../todayLong}} |
Use ../ to reach the tree root from inside {{#each}} (e.g. {{../todayLong}}).
Remittance details (online URL, check payee, bank/ABA/acct, phone) are hard-coded in
account-statement-openchecks/template.html — the client's own values, matching the legacy
Aspose document. They were briefly {{remit.*}} fields, but nothing in the engine produces
them, so the block rendered blank. If a per-environment remittance store lands later, these
five lines are the ones to convert back.
Handlebars helpers (parity between C# and preview)
| Helper | Behavior |
|---|---|
{{currency x}} |
en-US $#,##0.00; negatives in parentheses; null/unparsable → $0.00 |
{{number x}} / {{number x 2}} |
thousands separators; default up to 2 decimals with trailing zeros trimmed; x 2 fixes 2 decimals; unparsable → "" |
{{formatDate x "M/d/yyyy"}} |
.NET-style date tokens; default M/d/yyyy; bad input → "" |
{{sum array "prop.path"}} |
decimal sum over a dotted path |
{{#groupBy array "prop.path"}} |
iterates groups with context { key, items } |
{{#if (eq a b)}} / ne / gt / gte / lt |
numeric compare when both parse as numbers, else string |
Numeric array indexing works in the C# renderer as {{items.0.name}}; the preview tool
normalizes that to the handlebars-js form automatically, so write for the C# renderer
(ts_invoice.0.ts_accountid.name).
Branding (colors, fonts, logo wordmark) lives in the single :root { --brand-* } block at the
top of template.html — edit those tokens, not scattered hex. See rebranding.md.
Print pagination (multi-page documents)
The final step is Chromium printing the HTML to PDF (PdfRenderer.cs, PreferCSSPageSize=true,
so the template's @page owns size + margins). Any template whose rows can spill onto a second
page needs explicit page-break rules — don't rely on the browser's defaults, which vary by
Chromium version and by the data. The classic failures are a line row splitting across the
boundary and a totals/summary block being torn (e.g. Subtotal/Tax on one page,
Total Due orphaned at the top of the next). Guard against both:
/* repeat the table header on every page */
table.lines thead { display: table-header-group; }
/* never split a row across a page break */
table.lines tbody tr { break-inside: avoid; }
/* keep a totals / summary / footer block whole (moves as a unit to the next page) */
.totals, .footer { break-inside: avoid; }
Other useful primitives: break-before: page / break-after: page to force a break (the
statement templates use .page-break { break-before: page } between invoices), and
break-inside: avoid on any block (a remittance panel, a signature box) that must never be
split. Verify with real data volumes — render a record with enough rows to cross a page
boundary and check the seam, not just a one-page sample. (psa-invoice carries exactly these
rules for its lines table, totals, and footer.)
Describing a layout change in plain language
You don't have to write Handlebars yourself. Templates are edited by handing a clear
request to whoever (or whatever — a developer or an AI assistant) edits template.html. A
good request names three things:
- Which template — the
code(e.g.psa-invoice). - Where — the section or columns ("the lines table", "the Bill To heading", "the totals box").
- What — the change, and what data to show. If the data isn't already in the template's datasets, it can't appear — say so and it becomes a dataset change first (§6).
Well-formed requests, and what each one changes under the hood:
| What you say | What changes |
|---|---|
"On psa-invoice, rename the Bill To heading to Client." |
Static label text in template.html (§9a) |
| "Add a Tax column to the lines table, after Amount." | New <th> + <td> cell; the field must exist on the line rows (else add it to datasets/lines.xml, §6) |
| "Show quantities with no decimals." | {{number quantity}} → {{number quantity 0}} |
| "Show the invoice date as 2026-07-23, not Jul 23, 2026." | {{formatDate createdon "MMM d, yyyy"}} → "yyyy-MM-dd" |
| "Only show the Project box when there's a project number." | Wrap it in {{#if project.number}} … {{/if}} |
| "Sort the lines newest first." | This is a dataset change — the <order> in datasets/lines.xml, not the template |
| "Make the accent color navy." | The --brand-* tokens in the :root block (§7 / rebranding.md) |
| "Group lines with the same project item and unit price into one row, summing quantity and amount." | Group + sub-total in the lines loop — worked below |
Requests that need data the datasets don't return (a new column, a different grouping key, a value from another table) are a dataset change first (§6), then a template change. If you ask for a field that isn't there, the safe outcome is an empty cell — so name the source.
Worked example — group invoice lines (psa-invoice)
The request: "On the psa-invoice template, group all invoice lines that share the same
project item and the same unit price into a single row. Show the summed quantity and the
summed amount for each group; keep the unit price."
First, the data must support it. Grouping by "project item" only works if each line row
carries that value. Today datasets/lines.xml selects productdescription, quantity,
priceperunit, extendedamount — there is no project-item column, so add it to the
FetchXML first (a §6 dataset change), e.g. <attribute name="ts_projectitem" /> (use your
org's real schema name). If "same project item" is effectively "same description", you can skip
this and group by productdescription, which is already present.
Then the template. The groupBy helper groups by one key, so group by project item,
then by unit price inside it; sum totals each innermost group. Replace the lines loop:
<!-- BEFORE: one row per line -->
{{#each lines}}
<tr>
<td>{{#if productdescription}}{{productdescription}}{{else}}—{{/if}}</td>
<td class="num">{{number quantity}}</td>
<td class="num">{{currency priceperunit}}</td>
<td class="num">{{currency extendedamount}}</td>
</tr>
{{/each}}
<!-- AFTER: one row per (project item, unit price) group -->
{{#groupBy lines "ts_projectitem"}}
{{#groupBy items "priceperunit"}}
<tr>
<td>{{#if items.0.productdescription}}{{items.0.productdescription}}{{else}}—{{/if}}</td>
<td class="num">{{number (sum items "quantity")}}</td>
<td class="num">{{currency key}}</td>
<td class="num">{{currency (sum items "extendedamount")}}</td>
</tr>
{{/groupBy}}
{{/groupBy}}
- The inner
keyis the sharedpriceperunit;itemsare all lines with that project item and price.{{sum items "quantity"}}and{{sum items "extendedamount"}}add them up. - Swap
ts_projectitemfor the real project-item field (orproductdescriptionif grouping by description).{{number (sum …)}}/{{currency (sum …)}}are subexpressions — helpers feeding helpers. - The totals box is unaffected —
totallineitemamount/totaltax/totalamountare invoice-level fields, so regrouping the display rows doesn't change the invoice total.
This doesn't break the "logic lives in the Enricher" rule.
groupBy/sumhere are presentation-layer regrouping — collapsing already-correct rows for display, while the authoritative figures (the invoice total, tax) stay pre-computed in the data and are merely rendered. That's the boundary: cosmetic roll-ups of existing rows are fine in a template. Anything business-critical — a subtotal that drives a payment, a tax calculation, a value presented as authoritative, or grouping by a rule that could change — belongs in the Enricher (§8) or the dataset, with the template rendering the pre-shaped rows/values. If the document's correctness would depend on the template's arithmetic, move it out of the template.
Preview it before seeding (psa-invoice needs a sample.json first — capture one with
npm run capture-sample psa-invoice -- --entity-id <guid> --env dev, which already includes the
enrichment fields), then npm run seed psa-invoice -- --env dev.
8. Enrichment — computed values & business logic
All derived numbers, addresses, and labels are computed in
src/SssDocGen.Api/Services/Enricher.cs (StatementEnricher) so templates stay logic-light.
An enricher applies to a template based on its code: StatementEnricher.AppliesTo returns
true when the code contains "statement". It computes, per invoice row: totals, flags,
amountDueLabel, creditBalanceMessage, paymentTermsText, billName, billAddress,
clientNumber, and the six aging buckets — plus today/todayLong at the tree root.
When to touch the Enricher: any time a template needs a value that isn't a raw dataset
column — a sum, a formatted address, a conditional label, a bucket. Add it here, cover it with
a test (dotnet test), and reference it from the template. Register new enricher classes in
DI (Program.cs, AddSingleton<IEnricher, …>); the EnricherRegistry resolves all that
AppliesTo(code) for a given template.
9. Changing labels
"Labels" come in three flavors — know which one you're editing:
a) Static text baked into template.html
Section headings, column headers, banners, footer text ("STATEMENT OF ACCOUNT", "ACCOUNT
SUMMARY", "Invoice Detail", "Aging Summary", "Thank you for your business!", table <th>s,
etc.) are literal HTML in template.html. To change one, edit the text in the file,
npm run preview to check, then npm run seed. These are per-template.
b) Dynamic labels computed by the Enricher
Some labels change with the data and are therefore fields, not literals:
| Field | Values / logic (in Enricher.cs) |
|---|---|
amountDueLabel |
"CREDIT BALANCE" when the account is in credit, else "NET AMOUNT DUE" |
creditBalanceMessage |
the credit-balance sentence when in credit, else null (template guards with {{#if}}) |
paymentTermsText |
"upon receipt" when terms = 0 days, else "within N days" |
To change the wording of these, edit the string literals in StatementEnricher (e.g. the
"CREDIT BALANCE" / "NET AMOUNT DUE" ternary, or the credit-balance message), update the
matching unit test, dotnet test, and redeploy the API. Changing them in the template is not
possible — they're supplied by the engine.
c) Bilingual UI labels on the config page
Field labels, buttons, and headings on the Document Templates generative page are EN/PT
strings in genpages/docgen-template-config/docgen-template-config.tsx. Edit them there and
redeploy the page per ../genpages/docgen-template-config/README.md.
These affect the maker UI only, not the generated documents.
10. Shipping changes to an environment
- Make and preview changes locally (
preview,dotnet test). - Seed template/dataset config:
npm run seed <code> -- --env dev(dry-run first; run twice to confirm idempotency). Repeat fortst, thenprd. - Engine changes (Enricher, helpers, API) ship via the container image /
deployworkflow — see the CI in.github/workflows/and thedeployment-runbook.md. - Never hand-edit template rows in production except as a hotfix, and commit the fix back to the repo so the next seed doesn't overwrite it.
A merged/finished template row in prod is authoritative only until the next seed — the repo is the source of truth.
11. Troubleshooting & gotchas
| Symptom | Likely cause / fix |
|---|---|
404 Template not found |
Code doesn't match any sss_code; seed the template, check the folder name |
preview: Template not found |
The code doesn't match any templates/**/manifest.json. A bare code, <group>/<code> and templates/<group>/<code> are all accepted |
| Preview looks right, PDF is empty/odd | Enrichment fields missing at runtime, or a helper edge case — confirm the field is produced by the Enricher for this code (AppliesTo) |
| Child rows attach to every parent | parentKeyField didn't resolve — check the child key exists on the parent dataset. The API logs a warning for this; see the container logs in Log Analytics |
| Seed reports unexpected deletes | A dataset was removed/renamed in the manifest → its old row is an orphan; intended renames are fine |
| First request slow (~10-20 s) | Cold start — the container scales to zero by design |
| Template returns 404 but exists | It is deactivated. The API only serves active rows, matching what the command-bar button already showed |
| Document renders but looks wrong | Check Warnings in the response before the template — a truncated dataset or an unresolved parent key produces plausible, wrong output |
500 invalid parent on the annotation |
targetEntity must have notes (annotations) enabled and the app user needs Create-on-Note; see deployment-runbook.md #10 |
Quick reference
# Preview a template locally (bare code, group/code or templates/group/code; sample.json by default)
npm run preview account-statement-openchecks
npm run preview account-statement-openchecks -- --data sample-credit.json --open
# Check a template's bindings against its sample data (-- --all for the whole catalog)
npm run lint-template account-statement-openchecks
# Capture sample data (ENRICHED tree via Output=data; no writes; synthetic-data envs only)
npm run capture-sample account-statement-openchecks -- --entity-id <guid> --env dev
# Seed config to Dataverse (idempotent; dry-run first) — bare code OK, or --all
npm run seed account-statement-openchecks -- --env dev --dry-run
npm run seed account-statement-openchecks -- --env dev
npm run seed -- --all --env dev
# Build & test the engine
dotnet build sss-docgen.sln && dotnet test