Typst templates
Every invoice PDF uninvoice.app renders is built from a fixed layout (header, recipient, line items, totals, tax breakdown, QR code) plus two snippets you control:
| Snippet | Where it renders |
|---|---|
| Post-invoice block | Directly under the totals table, before the payment accounts. Typically payment instructions, terms, or a thank-you note. |
| Footer | At the bottom of every page, centred and in small type (8pt). Typically a legal notice or registry data. |
Both snippets are written in Typst, a modern typesetting language. This page covers what you can write in them; the values you can interpolate are covered in Placeholders.
Where to set your snippets
- Company-wide: the Invoice settings card on the Company page. These apply to every invoice you issue.
- Per recipient: the invoice template override on a customer's entity form. These apply only to invoices issued to that customer.
The two combine differently:
- The post-invoice override does not discard the company block. The
company's block is handed to your override as a function,
#post_invoice_original(), which you call wherever you want it, so a recipient can add a section above or below the standard payment instructions without copying them. - The footer override replaces the company footer entirely. If you want to keep the company footer's content, repeat it in the override.
The effective snippets are frozen onto each invoice when it is created, the same way company and recipient details are. Editing a template never changes an invoice you already created. A document that was issued stays exactly as it was rendered.
Typst in one minute
A snippet is ordinary Typst markup: text is text, and # switches into
code: a function call, a variable, or a control-flow expression.
Payment is due within 30 days.
*Bank transfer* to #ph("payment_recipient"), quoting #ph("payment_concept").
#text(size: 9pt, fill: gray)[Thank you for your business.]
Useful entry points in the Typst reference:
| What you want | Typst reference |
|---|---|
Markup rules (*bold*, _italic_, lists, escapes) | Syntax |
| Size, weight, colour, font | text |
| Vertical / horizontal space | v, h |
| Centre, right-align, indent a block | align, block |
| Columns of content | grid |
| A bordered data table | table |
| Bullet and numbered lists | list, enum |
| Hyperlinks | link |
| Rules, boxes, colours | line, rect, rgb |
Variables, if, loops, arrays | Scripting |
Helpers available in your snippets
On top of standard Typst, uninvoice.app defines a small set of helpers bound to the invoice being rendered.
ph("key"): a placeholder value
Returns the value of a built-in or custom placeholder, or an empty string when the invoice carries no value for that key. Because it never fails, optional sections can collapse cleanly:
#if ph("purchase_order") != "" [
Purchase order: #ph("purchase_order")
]
The full list of keys is in Placeholders.
total_numeric: the invoice total as a number
The invoice total in major units (euros, not cents) as a plain number, for arithmetic. Everything else is text.
fx_rate(from, to) and convert(from, to, amount): currency conversion
fx_rate returns the unit rate for a currency pair; convert multiplies an
amount by that rate. Both return none when the invoice carries no rate for the
pair, so guard the block:
#let usd = convert("EUR", "USD", total_numeric)
#if usd != none [
Equivalent: #calc.round(usd, digits: 2) USD
(1 EUR = #fx_rate("EUR", "USD") USD)
]
uninvoice.app scans your snippets for these calls before rendering in order
to fetch the rates and freeze them onto the invoice. Only literal codes are
found: convert("EUR", "USD", …) works, convert(base, quote, …) does not:
computed codes silently resolve to none.
Rates come from the European Central Bank's reference rates, which cover roughly 30 currencies. A rate can be entered by hand when creating the invoice; anything still missing is fetched when the invoice is issued and frozen onto it, so a rendered conversion never moves afterwards. If a required pair cannot be resolved (an unsupported currency, or the rate provider being unreachable), issuing fails and the invoice stays a draft rather than rendering a blank amount.
post_invoice_original(): the company's post-invoice block
Only meaningful in a per-recipient post-invoice override: it renders the company-wide block. Elsewhere it renders nothing, so calling it is always safe.
This invoice is covered by framework agreement #ph("agreement_ref").
#post_invoice_original()
#text(size: 8pt)[Send remittance advice to [email protected].]
What a snippet cannot do
The renderer is deliberately sealed: the same input always produces the same PDF, on any machine.
- No external packages or imports.
#import "@preview/…"and#includeof other files are not available. - No external files. Images, fonts, or data loaded from disk or a URL are not available; only the invoice's own data is.
- Fonts are the bundled ones:
Helvetica Neue(the document default),Ubuntu, andUbuntu Mono. Any other family falls back. - No "today".
datetime.today()is pinned to a fixed date so rendering is reproducible; useph("invoice_created_at")for the invoice's date. - A snippet that does not compile breaks the PDF for every invoice that carries it. Since snippets are frozen per invoice, a broken template affects invoices created while it was in place, not the ones created before.
Create a draft invoice for the recipient and download its PDF. Drafts render
through exactly the same pipeline (with a DRAFT watermark), so it is the
fastest way to check a snippet compiles and looks right.
Where snippets do not render
The post-invoice block is skipped on proformas and on invoices whose payment details are suppressed (for example an invoice already collected by card), since both would otherwise ask for a payment that must not be made. The footer renders on every document.