Templates
ScaiControl ships a visual template designer for invoices and emails. Templates are stored in the invoice_templates table, edited via a GrapesJS-based canvas in the admin UI, rendered with Jinja2 + WeasyPrint (PDFs) or Jinja2 alone (emails).
What is a template#
A template is identified by the triple (name, document_type, language):
name— operator-chosen, e.g. "ScaiLabs Standard Invoice". Same name spans multiple language variants.document_type— one of:invoice— PDFcredit_note— PDF (rendered the same way, butcredit_note_default.htmlis the fallback)email_invoice— HTML emailemail_credit_note— HTML email
language— ISO 639-1 lowercase code (en,nl,de,fr,es,it) or NULL for the any-language fallback.
A template carries:
subject— only for email typeshtml_content— the GrapesJS-saved body markupcss_content— the CSS authored in the canvaseditor_data— opaque GrapesJS project state (not used at render time but stored for round-trips)version— incremented on every save
Resolution chain#
When ScaiControl needs to render an invoice or email, it walks this chain (highest priority first):
- Tenant's
invoice_template_name(oremail_template_name) + tenant'spreferred_language - Tenant's template name + the any-language (NULL) variant
- Partner's template name + tenant's preferred_language
- Partner's template name + any-language variant
- Built-in filesystem template (
services/billing/templates/invoice_default.html,credit_note_default.html)
The first hit wins. This means a tenant can override its partner, and a partner can override the platform default.
Designer UX#
/admin/billing/templates lists every template, grouped by (name, document_type). Inside each row, every language variant is listed; clicking opens the designer at /admin/billing/templates/designer?name=…&type=…&lang=….
The designer:
- Drag-and-drop blocks for sections (header, invoice details, line items table, totals, bank info, footer).
- Variable picker — every Jinja variable that's in scope at render time, browsable by group (Seller, Buyer, Document, Loops, Conditionals).
- Live preview that renders the canvas with sample data through the same backend pipeline that finalisation uses.
- Page-format picker (A4, Letter, A5) for PDFs.
- "Send test email" button for email types.
- Language switcher — load a different language variant; "Copy current to another language" for fast localisation.
Jinja round-trip#
GrapesJS is HTML-aware, not Jinja-aware. Naively storing {% if x %}…{% endif %} in the canvas breaks the HTML5 parser (table foster-parenting moves block-level Jinja tags out of <tbody>), and the designer's auto-classification of text components misbehaves when it sees raw Jinja tokens.
To work around both issues, ScaiControl encodes Jinja control syntax as HTML comments before loading into the canvas and decodes on save:
1 2 | |
{{ var }} (expressions, no side effects) survive as-is.
In the designer canvas:
- HTML comments are invisible, so the user sees the wrapped content rendered statically.
- The auto-applied
data-jinja-cond="if x"decoration draws an amber dashed border around any element wrapped in{% if %}/{% for %}blocks, with a small label. This is canvas-only (stripped at save time).
The frontend validation calls POST /admin/billing/templates/designer/validate on every change (debounced); the backend compiles the HTML through Jinja2 and returns errors (line + reason) so the admin sees broken syntax before saving.
Page format and WeasyPrint quirks#
PDF templates are rendered with WeasyPrint via services/billing/template_renderer.py:render_pdf. Things to know:
- Blogger Sans is embedded as base64 data URLs in the
@font-facedeclarations. This avoids a runtime fetch and ensures consistent rendering offline. - SVG images with
width="100%" height="100%"get those attributes stripped by a custom WeasyPrinturl_fetcher. Without this, WeasyPrint scales the SVG to fill its container instead of honouring the author's CSSwidth. @media (max-width: …)-wrapped rules are not part of print rendering. GrapesJS occasionally wraps style rules in media queries when the user resizes elements on the non-default device — those rules don't match in WeasyPrint's print media and silently disappear. The designer config now setswidthMedia: ''on every device so future edits stay unscoped, but already-saved templates may need a one-time SQL fix.
Email rendering#
Email templates render via services/billing/template_renderer.py:render_email_template. Both subject and HTML are Jinja-rendered with the same context shape as invoices (seller, buyer, document, total, line_items, vat_breakdown, notes, reverse_charge). Plain-text fallback is auto-derived from the HTML by stripping tags + <style> / <script> contents.
The CSS injected into emails is the designer's own — no equivalent of base.css gets layered on top (it would have for PDFs early on; same fix applied to both).
Default formatting#
Localisable bits:
- Dates:
%d-%m-%Y(European convention).2026-05-04→04-05-2026. - Currency: European thousands/decimal —
€ 1.234,56. Currency-symbol lookup table covers EUR/USD/GBP; other currencies fall back to a three-letter prefix. - Legal notes: localised at render time per
preferred_language.
Validation on save#
The POST /admin/billing/templates/designer upsert endpoint compiles both subject and html_content through Jinja2 BEFORE writing to the DB. A TemplateSyntaxError returns 400 with Invalid Jinja syntax in <field> (line N): <message>. This means a broken template can't be silently saved and later blow up at PDF generation time.
The CLI also has a validator for ad-hoc / CI use:
1 2 3 | |
Non-zero exit on any failure.