Skip to content

Templates Invoice

Alexander Elchlepp edited this page May 24, 2026 · 3 revisions

Templates: Invoice PDF / Invoice Email

Applies to:

  • TEMPLATE_INVOICE_PDF
  • TEMPLATE_INVOICE_EMAIL

Both template types share the same render context. The email variant is used by the workflow engine to send invoice-related emails (e.g. payment reminders). PDF-specific elements like <div class="header"> / <div class="footer"> are not supported in email templates.

Root Variables (Render Context)

  • invoice (Invoice)
  • vats (Array, key/value)
  • brutto, netto, bruttoFormated, nettoFormated
  • periods, numbers (Arrays)
  • appartmentTotal, miscTotal

vats Structure

vats is an associative array grouped by VAT rate.

  • Key: VAT rate (for example 7, 19)
  • Value: object with these fields:
  • value.brutto: gross amount for this VAT rate
  • value.netto: VAT amount for this VAT rate
  • value.netSum: net base amount (brutto - netto) for this VAT rate
  • value.nettoFormated: formatted VAT amount (string, de_DE number format)

Example shape:

vats = {
  7:  { brutto: 107.00, netto: 7.00,  netSum: 100.00, nettoFormated: '7,00' },
  19: { brutto: 119.00, netto: 19.00, netSum: 100.00, nettoFormated: '19,00' }
}

Note on naming:

  • top-level brutto = total gross sum
  • top-level netto = total VAT sum
  • top-level nettoFormated = formatted net base sum (brutto - netto)

Examples

Header information:

<p>Invoice [[ invoice.number ]] from [[ invoice.date|date('d.m.Y') ]]</p>

VAT key/value loop:

<tr data-repeat="vats" data-repeat-key="key" data-repeat-as="value">
  <td>[[ key ]] %</td>
  <td>Net base: [[ value.netSum ]] €</td>
  <td>VAT: [[ value.nettoFormated ]] €</td>
  <td>Gross: [[ value.brutto ]] €</td>
</tr>

Hiding the 0% bucket on display: The vats array intentionally keeps a 0 bucket when zero-rated positions exist — its brutto is still part of the invoice total and is summed up in brutto / netto. If you want to hide it use filter as shown below:

<tr data-repeat="vats|filter((v, k) => k > 0)" data-repeat-key="key" data-repeat-as="value">
  <td>[[ key ]] %</td>
  <td>[[ value.nettoFormated ]] €</td>
</tr>

Apartment positions:

<tr data-repeat="invoice.appartments" data-repeat-as="appartment">
  <td>[[ appartment.description ]]</td>
  <td>[[ appartment.totalPrice ]] €</td>
</tr>

Position Groups (invoice.positions)

Every entry of invoice.positions (an InvoicePosition) carries an optional positionGroup marker that allows you to render different kinds of positions in dedicated tables. Possible values:

  • apartment_modifier — surcharges / discounts attached to an apartment (e.g. last-minute discount, pet fee). Rendered in the UI in a separate table above the tourist-tax table.
  • tourist_tax — tourist tax / accommodation tax (Kurtaxe / Beherbergungsabgabe). One position per guest/night bucket; rendered in the UI in a dedicated table.
  • null or any other value — ancillary services / "weitere Leistungen" (breakfast, cleaning fee, …).

The total miscTotal is the sum of all entries of invoice.positions, including modifier and tourist-tax positions. If you split them into separate tables, decide whether you want a single grand total at the bottom (recommended, keep using [[ bruttoFormated ]]) or per-group subtotals via Twig {% set %}.

Filtering by position group

Use Twig's |filter inside data-repeat and data-if:

<!-- Beherbergungsabgabe / Kurtaxe -->
<table data-if="invoice.positions|filter(p => p.positionGroup == 'tourist_tax')|length > 0" style="width: 100%;">
  <thead>
    <tr><th>Beherbergungsabgabe</th><th>Anzahl</th><th>Einzelpreis</th><th>MwSt.</th><th>Gesamt</th></tr>
  </thead>
  <tbody>
    <tr data-repeat="invoice.positions|filter(p => p.positionGroup == 'tourist_tax')" data-repeat-as="position">
      <td>[[ position.description ]]</td>
      <td>[[ position.amount ]]</td>
      <td>[[ position.priceFormated ]] €</td>
      <td>[[ position.vat ]]</td>
      <td>[[ position.totalPrice ]] €</td>
    </tr>
  </tbody>
</table>

<!-- Aufschläge / Ermäßigungen -->
<table data-if="invoice.positions|filter(p => p.positionGroup == 'apartment_modifier')|length > 0" style="width: 100%;">
  <thead>
    <tr><th>Aufschläge / Ermäßigungen</th><th>Anzahl</th><th>Einzelpreis</th><th>MwSt.</th><th>Gesamt</th></tr>
  </thead>
  <tbody>
    <tr data-repeat="invoice.positions|filter(p => p.positionGroup == 'apartment_modifier')" data-repeat-as="position">
      <td>[[ position.description ]]</td>
      <td>[[ position.amount ]]</td>
      <td>[[ position.priceFormated ]] €</td>
      <td>[[ position.vat ]]</td>
      <td>[[ position.totalPrice ]] €</td>
    </tr>
  </tbody>
</table>

<!-- weitere Leistungen (alles außer tourist_tax und apartment_modifier) -->
<tr data-repeat="invoice.positions|filter(p => p.positionGroup != 'tourist_tax' and p.positionGroup != 'apartment_modifier')" data-repeat-as="position">
  <td>[[ position.description ]]</td>
  <td>[[ position.totalPrice ]] €</td>
</tr>

Notes:

  • data-if evaluates a plain Twig expression, so |filter(...)|length > 0 is the idiomatic way to hide an entire table when no positions of that group exist.
  • Filter expressions used in data-repeat are written verbatim into the generated {% for %}, so any Twig-compatible expression (|filter, |sort, |slice, …) is allowed.

Clone this wiki locally