Thermal Receipt Templates
FlowPOS generates ESC/POS thermal receipts from Handlebars templates stored in the document_template table. System defaults are seeded at migration time; per-business and per-location overrides require no code changes.
Supported document types
| Document type key | Receipt | documentType enum value |
|---|---|---|
sale_receipt | Retail sale | saleReceiptThermal |
order_receipt | Restaurant order | orderReceiptThermal |
order_bill_receipt | Restaurant bill (cuenta de mesa) | orderBillReceiptThermal |
Resolution order
For every print job the backend resolves the template in this order:
- Location override — a row in
location_template_configbinding a specific template to this location + document type - Business override — a
document_templaterow wherebusiness_id = <businessId>anddocument_type = <type> - System default — a
document_templaterow wherebusiness_id IS NULLandis_system = true - File fallback — the
.template.txtfile on disk (dev environment / migration lag safety net)
Resolution happens at job-fetch time (GET /document-print-jobs/:id?cols=42). The print-bridge receives pre-rendered Line[] objects and feeds them straight into the ESC/POS encoder — no template logic runs on the bridge side.
Template DSL
Template bodies are Handlebars with a bracket-tag layer on top.
Bracket tags
| Tag | Effect |
|---|---|
[BOLD] … [/BOLD] | Bold text |
[CENTER] … [/CENTER] | Center-aligned line |
[SEP=<char>] | Full-width separator, e.g. [SEP==] or [SEP=-] |
Unknown tags are stripped silently (safe degradation for future additions).
Handlebars helpers
| Helper | Signature | Description |
|---|---|---|
money | (amount, currencyCode) | Formats a number as a currency string, e.g. Q1.25 |
truncate | (str, n) | Truncates a string to n characters |
rightAlign | (left, right) | Pads left and right so right is flush against the right margin |
fmtDate | (isoDate) | Formats an ISO date using the printer's timezone and locale |
itemCount | (items) | Returns the sum of item.quantity across all items |
sumItems | (items) | Returns the sum of item.total across all items |
ifDifferent | (a, b) block | Renders block only when ` |
saleItemLine | (qty, name, total, currencyCode) | Right-aligned Nx Name Q1.25 line |
saleDiscountLine | (discount, currencyCode) | Indented discount row |
orderReceiptItemLine | (qty, name, total, currencyCode) | Right-aligned order item line |
orderBillItemRow | (qty, name, unitPrice, currencyCode) | N P.U. Name Q1.25 |
orderBillTotalRow | (total, currencyCode) | Right-aligned item subtotal row |
paymentsChangeLine | (payments, total, currencyCode) | Change row, or empty string if no change due |
Column width (_cols) and locale/timezone (_timezone, _locale) are injected automatically into the template context — helpers read them from options.data.root.
System default templates
The three default template files live in:
apps/backend/src/pdf/infrastructure/templates/
sale-receipt-thermal.template.txt
order-receipt-thermal.template.txt
order-bill-receipt-thermal.template.txt
They are seeded into document_template by migration 2026-04-17t02-00-00-seed-thermal-receipt-templates.mjs using stable UUIDs:
| UUID | Document type |
|---|---|
f1e2d3c4-b5a6-7890-1234-567890abcdef | saleReceiptThermal |
e2d3c4b5-a6f7-8901-2345-67890abcdef1 | orderReceiptThermal |
d3c4b5a6-f7e8-9012-3456-7890abcdef12 | orderBillReceiptThermal |
Changing the system default
Template body only (labels, layout, sections)
- Edit the corresponding
.template.txtfile. - Sync the DB row with a migration or a direct SQL update:
UPDATE document_template
SET html_template = '<new body>'
WHERE id = 'd3c4b5a6-f7e8-9012-3456-7890abcdef12'; -- order-bill
-- f1e2d3c4-b5a6-7890-1234-567890abcdef → sale
-- e2d3c4b5-a6f7-8901-2345-67890abcdef1 → order receipt
The .template.txt file is the source of truth — always keep it in sync with the DB row.
Adding new formatting capabilities
- Register a new Handlebars helper in
ensureHelpers()— thermal-template-renderer.adapter.ts - If the new capability requires a new bracket tag (e.g.
[DW]for double-width), extendparseLine()in the same file and add the new property to theLinetype - If the bridge needs to encode the new property, update
encodeLines()in renderer.ts - Use the new tag or helper in the
.template.txtfile
Per-client custom template
No code or deploy needed.
- In the PWA admin go to Templates → New template
- Set
documentTypeto the relevant thermal type (e.g.orderBillReceiptThermal) - Set
businessIdto the client's business ID - Write the custom DSL body
- Optionally bind it to a specific location via Location Template Config
The resolver will automatically serve the business or location override for that client and fall back to the system default for everyone else.
Payload variables reference
Sale receipt (saleReceiptThermal)
| Variable | Type | Description |
|---|---|---|
businessName | string | Business display name |
businessAddress | string? | Business address |
businessTaxId | string? | NIT |
locationName | string? | Location name |
receiptNumber | string? | Internal receipt number |
documentNumber | string? | FEL or alternative document number |
saleDate | string | ISO date of the sale |
customerName | string? | Customer name |
customerTaxId | string? | Customer NIT |
items | array | { quantity, name, total, discount? } |
subtotal | number | Pre-tax subtotal |
taxBreakdown | array | { label, amount } |
discountTotal | number? | Total discount applied |
total | number | Grand total |
currencyCode | string | e.g. GTQ |
payments | array | { method, amount, reference? } |
change | number? | Change due |
felAuthorization | string? | FEL authorization code |
felNumber | string? | FEL document number |
felSerialNumber | string? | FEL series |
Order receipt (orderReceiptThermal)
| Variable | Type | Description |
|---|---|---|
businessName | string | Business display name |
locationName | string? | Location name |
orderNumber | string | Order number |
orderDate | string | ISO date |
tableNumber | string? | Table number |
serverName | string? | Server/waiter name |
items | array | { quantity, name, total, modifiers?, notes? } |
subtotal | number | Pre-tax subtotal |
taxBreakdown | array | { label, amount } |
total | number | Grand total |
currencyCode | string | e.g. GTQ |
Order bill receipt (orderBillReceiptThermal)
| Variable | Type | Description |
|---|---|---|
businessName | string | Business display name |
businessAddress | string? | Business address |
businessTaxId | string? | NIT |
locationName | string? | Location name |
billNumber | string? | Bill number |
orderNumber | string | Source order number |
billDate | string | ISO date |
tableNumber | string? | Table number |
serverName | string? | Server/waiter name |
customerName | string? | Customer name |
customerTaxId | string? | Customer NIT |
items | array | { quantity, name, unitPrice, total, compReason?, modifiers?, notes? } |
subtotal | number | Pre-tax subtotal |
taxBreakdown | array | { label, amount } |
tipAmount | number? | Tip amount |
total | number | Grand total |
currencyCode | string | e.g. GTQ |
payments | array? | { method, amount, reference? } |
Preview paths — DSL renderer vs structured layout
There are two separate preview paths in the system. They are not interchangeable.
| Path | Where used | How it works |
|---|---|---|
DSL renderer (ThermalTemplateRendererAdapter) | Template Management UI — POST /pdf/templates/thermal-preview | Renders the stored html_template DSL body using Handlebars + bracket-tag parsing. Authoritative for custom thermal template editing and preview. |
Structured layout (buildDocumentReceiptLines) | WebSocket print job preview | Builds receipt lines from a structured DocumentPrintPayload object using the receipt-layout package. Used by the KDS/print bridge flow. |
Both outputs are Array<{ text, bold?, center? }> lines and look similar, but they come from different pipelines and may diverge when templates are customized. The DSL renderer is the source of truth for what a custom template body will actually print. For v1 these two paths are maintained in parallel — convergence is tracked as a follow-up.
template_content_kind column
The document_template table has a template_content_kind column (pdf_html | thermal_escpos_dsl) that classifies the rendering contract for each template. It is:
- Derived automatically from
document_typeinTemplateRepositoryon create/update — callers never set it directly. - Enforced by a DB CHECK constraint: thermal receipt document types must always be
thermal_escpos_dsl. - If you add a new thermal document type in a future migration, extend the CHECK constraint in the same migration.
Two options depending on what you want to change:
Option 1 — Edit the template body only (labels, layout, sections) Edit apps/backend/src/pdf/infrastructure/templates/order-bill-receipt-thermal.template.txt (or the sale/order variants).
Then update the seeded DB row so existing deployments pick it up:
UPDATE document_template
SET html_template = '<new body here>'
WHERE id = 'd3c4b5a6-f7e8-9012-3456-7890abcdef12'; -- order-bill
-- f1e2d3c4-b5a6-7890-1234-567890abcdef → sale
-- e2d3c4b5-a6f7-8901-2345-67890abcdef1 → order
Or add a new migration that does the UPDATE — cleaner for multi-env deployments.
Option 2 — Add new formatting capabilities (new tags, new helpers) Add the helper in ensureHelpers() in apps/backend/src/pdf/infrastructure/adapters/thermal-template-renderer.adapter.ts If it's a new bracket tag (e.g. [DW] for double-width), extend parseLine() in the same file If the bridge needs to handle a new line property (e.g. doubleWidth: true), update encodeLines() in apps/print-bridge/src/renderer.ts Then use the new tag/helper in the .template.txt file The .template.txt file is the single source of truth for the default layout. The DB row is just a copy of it seeded at migration time — always keep them in sync.