Skip to content

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: {{ variable }} to output values, {% 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:

html
<!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:

FilterExampleResult
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 &#124;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 (&#124;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 (&#124;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:

html
{% 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

VariableDescription
account.idAccount id
account.nameAccount name
account.balanceAccount balance
account.min_balanceAccount minimal balance threshold
account.max_balanceAccount maximal balance threshold
account.invoice_periodAccount invoice period name

contractor

VariableDescription
contractor.nameContractor name
contractor.addressContractor address
contractor.phonesContractor phones

invoice

VariableDescription
invoice.idID of generated invoice
invoice.referenceReference of generated invoice
invoice.created_atDate and time of invoice creation
invoice.start_dateBegin of the invoice period
invoice.end_dateEnd of the invoice period
invoice.amount_totalInvoice total amount
invoice.amount_spentAccount spent amount
invoice.amount_earnedAccount earned amount

Originated (account acts as customer) and terminated (account acts as vendor) traffic summaries have the same structure — invoice.originated.* and invoice.terminated.*:

VariableDescription
invoice.originated.calls_countCount of calls
invoice.originated.successful_calls_countCount of successful calls
invoice.originated.calls_durationCalls duration in seconds
invoice.originated.amount_spentAmount spent by account for these calls
invoice.originated.amount_earnedAmount earned by account for these calls (in case of reverse billing)
invoice.originated.first_call_atTime of first call
invoice.originated.last_call_atTime of last call

Services summary:

VariableDescription
invoice.services.amount_spentAmount spent for services
invoice.services.amount_earnedAmount earned for services
invoice.services.transactions_countServices transactions count

Detail lists

For building per-destination and per-network tables, iterate these lists with {% for %}:

ListDescription
originated_destinationsCalls originated by account, grouped by destination prefix
originated_networksCalls originated by account, grouped by destination network
terminated_destinationsCalls terminated to account, grouped by destination prefix
terminated_networksCalls terminated to account, grouped by destination network
service_dataServices-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:

VariableDescription
prefixDestination prefix (destination lists only)
countryCountry
networkNetwork name
ratePer minute rate
calls_countCount of calls
successful_calls_countCount of successful calls
calls_durationCalls duration in seconds
amountPrice of calls
first_call_atTime of first call
last_call_atTime 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:

VariableDescription
serviceService name
transactions_countCount of billing transactions related to service
amountTotal amount of transactions

Page setup and fonts

Page size and margins are controlled with regular CSS in the template:

css
@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:

css
@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
html
{# 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>