Invoice Templates
Invoice templates define the PDF documents generated for Invoices. Starting from yeti-web 1.15.26 a template is an HTML document with pongo2 (Jinja2/Django-style) variables, tags, and filters. During invoice generation the template is merged with the invoice data and rendered to PDF.
Templates are edited directly in the web interface and stored in the database. System can store many different templates and you can choose desired template for each account independently.
Legacy ODT templates
Older versions used ODT files with placeholders. That mechanism is not available in version 1.15; it is documented at Invoice Templates (ODT, before 1.15.26).
Rendering requires the yeti-pdf service — see Invoice PDF generation for installation and configuration.
Invoice Template attributes
- Id
- Unique invoice template id.
- Name
- Unique invoice template name.
- HTML template
- The HTML template body.
- Created at
- Date and time of the invoice template creation.
Template Playground
The Template Playground is an interactive editor for developing a template against real invoice data, without saving. Open an invoice template and click Template Playground: the left pane is a code editor pre-filled with the template, the top-right selector picks an invoice whose data is used for the render, and the right pane shows the resulting PDF. The preview re-renders automatically as you edit or when you pick a different invoice.
- Save — persist the edited template.
- Rollback — reload the saved template, discarding edits.
- Close — return to the template page.
Nothing is stored until you press Save, so you can experiment freely.
Template syntax
Templates use pongo2 syntax: to output values, {{ variable }}{% for %}/{% if %} tags for control flow, and |filter to format values.
Values in the invoice data are raw and formatted in the template with filters. Monetary and rate values (amounts, rates, balances) are exact decimal strings, so full precision is preserved; counts, durations (integer seconds) and ids are numbers; timestamps are ISO-8601 strings.
WARNING
Because the decimal values are strings, test them with a comparison - {% if d.amount > 0 %} - rather than a bare {% if d.amount %}: the string "0" is truthy. (pongo2 coerces the string for the numeric comparison.)
A template is a complete, self-contained HTML document:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@font-face { font-family: "DejaVu Sans"; src: url("DejaVuSans.ttf"); }
@font-face { font-family: "DejaVu Sans"; font-weight: bold; src: url("DejaVuSans-Bold.ttf"); }
@page { size: A4; margin: 18mm 16mm; }
body { font-family: "DejaVu Sans"; font-size: 10pt; color: #222; }
table { width: 100%; border-collapse: collapse; margin-top: 8pt; }
th, td { border-bottom: 1px solid #ddd; padding: 5pt 4pt; text-align: left; }
tfoot td { font-weight: bold; border-top: 2px solid #333; border-bottom: none; }
</style>
</head>
<body>
<h1>Invoice {{ invoice.reference }}</h1>
<div>Issued {{ invoice.created_at|strfdate:"%d.%m.%Y" }}</div>
<div>Period {{ invoice.start_date|strfdate:"%d.%m.%Y" }} –
{{ invoice.end_date|strfdate:"%d.%m.%Y" }}</div>
<div><strong>{{ account.name }}</strong> / {{ contractor.name }}</div>
<table>
<thead>
<tr><th>Destination</th><th>Calls</th><th>Duration</th><th>Amount</th></tr>
</thead>
<tbody>
{% for d in originated_destinations %}
<tr>
<td>{{ d.prefix }}</td>
<td>{{ d.calls_count|number:0 }}</td>
<td>{{ d.calls_duration|duration:"colon" }}</td>
<td>{{ d.amount|money:"€" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="3">Total</td><td>{{ invoice.amount_total|money:"€" }}</td></tr>
</tfoot>
</table>
</body>
</html>WARNING
File-access tags (include, extends, import, from, ssi) are disabled — a template must be self-contained.
TIP
Use the Template Playground to preview a template against a real invoice and iterate on it before saving.
Filters
Beyond the pongo2 built-in filters, these presentation filters are available:
| Filter | Example | Result |
|---|---|---|
money | {{ v|money:"€" }} | 1 234.5 € — space as thousands separator, optional currency. Param is currency:precision, both optional; precision is a maximum (default 2) with trailing zeros trimmed: {{ v|money }} → 1 234.5, {{ v|money:":8" }} → up to 8 decimals |
number | {{ v|number:2 }} | 9 876.54 — up to N decimals (trailing zeros trimmed), space as thousands separator |
sum | {{ list|sum:"amount" }} | totals a list attribute numerically (no key |sum totals a scalar list). Returns a raw number — pipe through money/number to display, or compare (list|sum:"amount" > 0). Chainable with where |
duration | {{ secs|duration:"colon" }} | 1:30 — input is seconds; modes: colon (M:SS, default), min (decimal minutes), human (1 m. 30 s.) |
strfdate | {{ v|strfdate:"%d.%m.%Y" }} | 30.06.2026 — formats an ISO‑8601 timestamp with strftime placeholders |
sort | {% for d in list|sort:"amount" %} | sorts a list of objects by a key; sort:"-amount" descending. Numeric when the values are numeric (amounts/rates/counts), lexical otherwise (names). With no key (|sort) sorts a scalar list |
where | {% for d in list|where:"successful_calls_count > 0" %} | keeps list items where the key passes a test (> >= < <= == !=); with just a key (|where:"successful_calls_count") keeps truthy ones. Chainable with sort |
required | {{ invoice.amount_total|required }} | renders the value, or aborts generation with an error if it is missing (nil / empty / whitespace). Optional message: {{ v|required:"amount_total is required" }} |
length | {{ list|length }} | item count of a list (a pongo2 built-in). Handy in a test: {% if list|where:"amount > 0"|length > 0 %}. There is no count/size alias |
The full set of pongo2 built-in filters (default, upper, lower, join, truncatechars, first/last, …) is also available.
Raising an error
The {% raise %} tag aborts invoice generation with an error — use it to guard against missing or unexpected data. The message is an optional string:
{% if account.currency == "UAH" %}
<dd>IBAN: UA493223130000026001234567890</dd>
{% elif account.currency == "RON" %}
<dd>IBAN: RO49AAAA1B31007593840000</dd>
{% else %}
{% raise "unsupported account currency: " + account.currency %}
{% endif %}For a simple "this field must be present" check, the required filter above is shorter than an {% if %} / {% raise %} pair.
Template data
The following variables are available in the template.
account
| Variable | Description |
|---|---|
account.id | Account id |
account.name | Account name |
account.balance | Account balance |
account.min_balance | Account minimal balance threshold |
account.max_balance | Account maximal balance threshold |
account.invoice_period | Account invoice period name |
contractor
| Variable | Description |
|---|---|
contractor.name | Contractor name |
contractor.address | Contractor address |
contractor.phones | Contractor phones |
invoice
| Variable | Description |
|---|---|
invoice.id | ID of generated invoice |
invoice.reference | Reference of generated invoice |
invoice.created_at | Date and time of invoice creation |
invoice.start_date | Begin of the invoice period |
invoice.end_date | End of the invoice period |
invoice.amount_total | Invoice total amount |
invoice.amount_spent | Account spent amount |
invoice.amount_earned | Account earned amount |
Originated (account acts as customer) and terminated (account acts as vendor) traffic summaries have the same structure — invoice.originated.* and invoice.terminated.*:
| Variable | Description |
|---|---|
invoice.originated.calls_count | Count of calls |
invoice.originated.successful_calls_count | Count of successful calls |
invoice.originated.calls_duration | Calls duration in seconds |
invoice.originated.amount_spent | Amount spent by account for these calls |
invoice.originated.amount_earned | Amount earned by account for these calls (in case of reverse billing) |
invoice.originated.first_call_at | Time of first call |
invoice.originated.last_call_at | Time of last call |
Services summary:
| Variable | Description |
|---|---|
invoice.services.amount_spent | Amount spent for services |
invoice.services.amount_earned | Amount earned for services |
invoice.services.transactions_count | Services transactions count |
Detail lists
For building per-destination and per-network tables, iterate these lists with {% for %}:
| List | Description |
|---|---|
originated_destinations | Calls originated by account, grouped by destination prefix |
originated_networks | Calls originated by account, grouped by destination network |
terminated_destinations | Calls terminated to account, grouped by destination prefix |
terminated_networks | Calls terminated to account, grouped by destination network |
service_data | Services-related billing transactions |
Each list contains every destination/network with any calls, including those where all calls failed (successful_calls_count = 0). To show only the ones that connected at least one call, filter in the template with the where filter — see the tip below.
Each row of the destination and network lists has:
| Variable | Description |
|---|---|
prefix | Destination prefix (destination lists only) |
country | Country |
network | Network name |
rate | Per minute rate |
calls_count | Count of calls |
successful_calls_count | Count of successful calls |
calls_duration | Calls duration in seconds |
amount | Price of calls |
first_call_at | Time of first call |
last_call_at | Time of last call |
Filtering and sorting a list
Lists arrive pre-ordered (destinations by prefix, networks by country/network) and contain all rows. Reshape them in the template with the where and sort filters, which chain:
- successful only —
{% for d in originated_destinations|where:"successful_calls_count > 0" %} - largest amount first —
|sort:"-amount" - both —
{% for d in originated_destinations|where:"successful_calls_count > 0"|sort:"-amount" %}
You can also filter inline with a plain {% if d.successful_calls_count > 0 %} inside the loop.
Each row of service_data has:
| Variable | Description |
|---|---|
service | Service name |
transactions_count | Count of billing transactions related to service |
amount | Total amount of transactions |
Page setup and fonts
Page size and margins are controlled with regular CSS in the template:
@page { size: A4; margin: 18mm 16mm; }DejaVu Sans (regular and bold) fonts are embedded in the yeti-pdf service and can be referenced by filename:
@font-face { font-family: "DejaVu Sans"; src: url("DejaVuSans.ttf"); }
@font-face { font-family: "DejaVu Sans"; font-weight: bold; src: url("DejaVuSans-Bold.ttf"); }
body { font-family: "DejaVu Sans"; }Additional fonts can be made available with the fonts_dir option of the yeti-pdf service configuration.
Full example
This template demonstrates every value the invoice payload provides and every filter, including pongo2 macros for repeated table markup. Copy it, delete the sections you don't need, and restyle.
Full example template
{# Example invoice template (yeti-pdf / pongo2). #}
{# Demonstrates every value the invoice payload provides and every filter. #}
{# Copy it, delete the sections you don't need, and restyle. #}
{# Values are raw — format with filters: #}
{# {{ amount|money:"€" }} -> 1 234.5 € (max 2dp, trimmed, space thousands) #}
{# {{ n|number:4 }} -> 0.0125 #}
{# {{ list|sum:"amount"|money:"€" }} -> sum of a column #}
{# {{ seconds|duration:"colon" }} -> 12:30 (M:SS) #}
{# {{ seconds|duration:"min" }} -> 12.50 (decimal minutes) #}
{# {{ seconds|duration:"human" }} -> 12 m. 30 s. #}
{# {{ ts|strfdate:"%d.%m.%Y %H:%M" }} -> 30.06.2026 23:59 #}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page { size: A4; margin: 16mm 14mm; }
body { font-family: sans-serif; font-size: 9pt; color: #222; }
h1 { font-size: 17pt; margin: 0 0 2pt; }
h2 { font-size: 12pt; margin: 16pt 0 4pt; border-bottom: 2px solid #333; padding-bottom: 2pt; }
h3 { font-size: 10pt; margin: 12pt 0 3pt; color: #444; }
.muted { color: #777; }
.header { display: flex; justify-content: space-between; margin-bottom: 10pt; }
.box { margin-bottom: 4pt; }
dl { margin: 0; }
dl.kv { display: grid; grid-template-columns: max-content 1fr; column-gap: 10pt; row-gap: 2pt; }
dl.kv dt { color: #666; }
dl.kv dd { margin: 0; }
table { width: 100%; border-collapse: collapse; margin-top: 4pt; }
th, td { border: 1px solid #bbb; padding: 3pt 5pt; }
th { background: #eee; text-align: left; }
td.num, th.num { text-align: right; }
tfoot td { font-weight: bold; background: #f5f5f5; }
.empty { color: #999; font-style: italic; padding: 4pt 0; }
</style>
</head>
<body>
{# ---- Header: issuer (contractor) + customer (account) + invoice meta ---- #}
<div class="header">
<div>
<h1>Invoice {{ invoice.reference }}</h1>
<div class="muted">No. {{ invoice.id }} · issued {{ invoice.created_at|strfdate:"%d.%m.%Y %H:%M" }}</div>
<div class="muted">Period {{ invoice.start_date|strfdate:"%d.%m.%Y" }} – {{ invoice.end_date|strfdate:"%d.%m.%Y" }}
{% if account.invoice_period %}({{ account.invoice_period }}){% endif %}</div>
</div>
<div>
<div class="box"><strong>{{ contractor.name }}</strong></div>
<div class="box muted">{{ contractor.address }}</div>
<div class="box muted">{{ contractor.phones }}</div>
</div>
</div>
<h2>Account</h2>
<dl class="kv">
<dt>Name</dt><dd>{{ account.name }} (#{{ account.id }})</dd>
<dt>Balance</dt><dd>{{ account.balance|money:"€" }}</dd>
<dt>Min / Max balance</dt><dd>{{ account.min_balance|money:"€" }} / {{ account.max_balance|money:"€" }}</dd>
</dl>
{# ---- Totals ---- #}
<h2>Summary</h2>
<dl class="kv">
<dt>Amount total</dt><dd><strong>{{ invoice.amount_total|money:"€" }}</strong></dd>
<dt>Spent</dt><dd>{{ invoice.amount_spent|money:"€" }}</dd>
<dt>Earned</dt><dd>{{ invoice.amount_earned|money:"€" }}</dd>
</dl>
{# Macro: render the originated/terminated summary block. #}
{% macro leg_summary(title, leg) %}
<h3>{{ title }}</h3>
<dl class="kv">
<dt>Spent / Earned</dt><dd>{{ leg.amount_spent|money:"€" }} / {{ leg.amount_earned|money:"€" }}</dd>
<dt>Calls (successful / total)</dt><dd>{{ leg.successful_calls_count|number }} / {{ leg.calls_count|number }}</dd>
<dt>Duration</dt><dd>{{ leg.calls_duration|duration:"colon" }}
({{ leg.calls_duration|duration:"min" }} min, {{ leg.calls_duration|duration:"human" }})</dd>
<dt>First / Last call</dt><dd>{{ leg.first_call_at|strfdate:"%d.%m.%Y %H:%M" }} / {{ leg.last_call_at|strfdate:"%d.%m.%Y %H:%M" }}</dd>
</dl>
{% endmacro %}
{{ leg_summary("Originated", invoice.originated) }}
{{ leg_summary("Terminated", invoice.terminated) }}
<h3>Services</h3>
<dl class="kv">
<dt>Spent / Earned</dt><dd>{{ invoice.services.amount_spent|money:"€" }} / {{ invoice.services.amount_earned|money:"€" }}</dd>
<dt>Transactions</dt><dd>{{ invoice.services.transactions_count|number }}</dd>
</dl>
{# Macro: a destinations table (has a prefix column). #}
{% macro destinations_table(rows) %}
<table>
<thead>
<tr>
<th>#</th><th>Prefix</th><th>Country</th><th>Network</th>
<th class="num">Rate</th><th class="num">Calls</th><th class="num">Success</th>
<th class="num">Duration</th><th class="num">Amount</th>
<th>First call</th><th>Last call</th>
</tr>
</thead>
<tbody>
{# skip rows with a zero (or missing) amount; keep sequential numbering #}
{% set i = 0 %}
{% for d in rows %}{% if d.amount %}{% set i = i + 1 %}
<tr>
<td class="num">{{ i }}</td>
<td>{{ d.prefix }}</td>
<td>{{ d.country }}</td>
<td>{{ d.network }}</td>
<td class="num">{{ d.rate|number:4 }}</td>
<td class="num">{{ d.calls_count|number }}</td>
<td class="num">{{ d.successful_calls_count|number }}</td>
<td class="num">{{ d.calls_duration|duration:"colon" }}</td>
<td class="num">{{ d.amount|money }}</td>
<td>{{ d.first_call_at|strfdate:"%d.%m %H:%M" }}</td>
<td>{{ d.last_call_at|strfdate:"%d.%m %H:%M" }}</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
{% if not rows %}<div class="empty">No data.</div>{% endif %}
{% endmacro %}
{# Macro: a networks table (aggregated by country/network — no prefix). #}
{% macro networks_table(rows) %}
<table>
<thead>
<tr>
<th>#</th><th>Country</th><th>Network</th>
<th class="num">Rate</th><th class="num">Calls</th><th class="num">Success</th>
<th class="num">Duration</th><th class="num">Amount</th>
<th>First call</th><th>Last call</th>
</tr>
</thead>
<tbody>
{# skip rows with a zero (or missing) amount; keep sequential numbering #}
{% set i = 0 %}
{% for n in rows %}{% if n.amount %}{% set i = i + 1 %}
<tr>
<td class="num">{{ i }}</td>
<td>{{ n.country }}</td>
<td>{{ n.network }}</td>
<td class="num">{{ n.rate|number:4 }}</td>
<td class="num">{{ n.calls_count|number }}</td>
<td class="num">{{ n.successful_calls_count|number }}</td>
<td class="num">{{ n.calls_duration|duration:"colon" }}</td>
<td class="num">{{ n.amount|money }}</td>
<td>{{ n.first_call_at|strfdate:"%d.%m %H:%M" }}</td>
<td>{{ n.last_call_at|strfdate:"%d.%m %H:%M" }}</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
{% if not rows %}<div class="empty">No data.</div>{% endif %}
{% endmacro %}
{# Every list holds all rows; use |where to keep only connected destinations #}
{# and |sort to order them. The two filters chain. #}
<h2>Originated destinations</h2>
{{ destinations_table(originated_destinations) }}
<h3>Originated destinations — successful only</h3>
{{ destinations_table(originated_destinations|where:"successful_calls_count > 0") }}
<h2>Terminated destinations</h2>
{{ destinations_table(terminated_destinations) }}
<h3>Terminated destinations — successful only</h3>
{{ destinations_table(terminated_destinations|where:"successful_calls_count > 0") }}
<h2>Originated networks</h2>
{{ networks_table(originated_networks) }}
<h3>Originated networks — successful only</h3>
{{ networks_table(originated_networks|where:"successful_calls_count > 0") }}
<h2>Terminated networks</h2>
{{ networks_table(terminated_networks) }}
<h3>Terminated networks — successful only</h3>
{{ networks_table(terminated_networks|where:"successful_calls_count > 0") }}
<h2>Service data</h2>
<table>
<thead>
<tr><th>#</th><th>Service</th><th class="num">Transactions</th><th class="num">Amount</th></tr>
</thead>
<tbody>
{# skip rows with a zero (or missing) amount; keep sequential numbering #}
{% set i = 0 %}
{% for s in service_data %}{% if s.amount %}{% set i = i + 1 %}
<tr>
<td class="num">{{ i }}</td>
<td>{{ s.service }}</td>
<td class="num">{{ s.transactions_count|number }}</td>
<td class="num">{{ s.amount|money }}</td>
</tr>
{% endif %}{% endfor %}
</tbody>
</table>
{% if not service_data %}<div class="empty">No data.</div>{% endif %}
</body>
</html>