Tutorial - 25 min

Genereer PDF-facturen

Deze tutorial voegt een afdrukbare PDF-factuur toe voor elke Deal in het mini-CRM dat je in Bouw je eerste app hebt gebouwd. Aan het einde heb je een factuur-template die het bedrijf, de regelitems en totalen van een deal ophaalt, live gerenderd in het preview-paneel van de editor en aanroepbaar vanuit externe systemen via REST.

Totale tijd: ongeveer 25 minuten. Je raakt PDF Templates aan en breidt het schema uit met een regelitem-tabel.

Trial-beperking: PDF-rendering is uitgeschakeld binnen de trial-omgeving om de gedeelde headless-Chromium-pool niet te overbelasten. Zowel het live preview-paneel van de editor (POST /api/v1/pdf-templates/{id}/preview) als het publieke POST /api/v1/pdf-templates/generate/<name>-endpoint geven 403 terug totdat je op een betaalde omgeving zit. Je kunt nog steeds de template schrijven (datascript, HTML, settings, parameters) en opslaan, het preview-paneel toont de 403 totdat rendering opnieuw wordt ingeschakeld. Plan om de gerenderde output op een niet-trial-omgeving te verifieren voordat je erop vertrouwt.

Vereisten

Je zou het mini-CRM uit Bouw je eerste app gedeployed moeten hebben: company, contact, deal-tabellen; hun Business Entities; de drie pagina's. Als je die tutorial hebt overgeslagen, zal de rest van deze pagina weinig zin hebben.

Stap 1 - Voeg regelitems toe aan het schema (5 min)

Een echte factuur heeft regelitems, niet alleen een enkel bedrag. Voeg een deal_line-tabel toe.

  1. Open Schema Designer, Add Table, noem hem deal_line.
  2. Voeg in de Columns-tab van het rechterpaneel toe:
    • id - SERIAL, PK (auto)
    • deal_id - INTEGER, required
    • description - VARCHAR(300), required
    • quantity - DECIMAL, required, default 1
    • unit_price - DECIMAL, required, default 0
    • line_total - DECIMAL, required, default 0 - afgeleid, hieronder in sync gehouden
  3. Schakel naar de Relations-tab. Voeg een relatie toe: FK column deal_id, Relation type One-to-Many, References table deal, References column id. Klik op Add Relationship.
  4. Klik op Deploy, bekijk de Generated SQL, klik op Deploy rechtsboven.

Bedraad de BE, pagina en een klein event

  1. Business Entities, Create. Entity Name deal_line, Master Table deal_line, Label Column description. Sla op.
  2. Open de bestaande Deals-pagina in Page Editor. Klik in het detailformulier op Add Tab, noem de tab Lines, en voeg een RelatedGrid- sectie toe gebonden aan deal_line met het joinfilter deal.id = current record's id. Sla op en publiceer opnieuw.
  3. Business Events, Create. Rule Name Calc deal_line.line_total. Enabled AAN. Business Entity deal_line. Triggers: Before Create + Before Update. Geen condities. Actie: Execute Script met body:
    Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
    Sla op.

Klik in een Deal, schakel naar de nieuwe Lines-tab, en voeg twee of drie regels toe. De line_total-kolom zou elke keer dat je een regel opslaat automatisch moeten worden gevuld.

Stap 2 - Schrijf het PDF-datascript (5 min)

Open PDF Templates, klik op + Create. De editor opent met een Name-veld bovenaan (typ DealInvoice), een Description-veld, en vijf tabs: HTML Template, Data Script, Settings, Params, JSON. Het PDF Preview-paneel is altijd zichtbaar rechts en rendert opnieuw wanneer je opslaat.

Schakel naar de Params-tab, klik op + Add. Name deal_id, Type int, Required aangevinkt. (Het tab-label wordt bijgewerkt naar Params (1) zodra je er een hebt gedefinieerd.)

Schakel naar de Data Script-tab, de Monaco C#-editor opent links, met de PDF-preview nog steeds rechts. Plak:

var deal = await Db.GetAsync("deal", deal_id);
if (deal == null) throw new Exception($"Deal {deal_id} not found");

var company = await Db.GetAsync("company", deal.company_id);

var lines = await Db.From("deal_line")
    .Where("deal_id", "=", deal_id)
    .OrderBy("id")
    .ToListAsync();

decimal subtotal = 0;
foreach (var l in lines) subtotal += (decimal)(l.line_total ?? 0);

var vatRate = 0.21m;       // Belgian standard VAT, adjust per customer
var vat     = Math.Round(subtotal * vatRate, 2);
var total   = subtotal + vat;

return new {
    InvoiceNumber = $"INV-{deal.id:D6}",
    InvoiceDate   = DateTime.UtcNow.ToString("yyyy-MM-dd"),
    Deal          = new { Title = (string)deal.title, Stage = (string)deal.stage },
    Company       = new { Name = (string)company.name, Industry = (string?)company.industry },
    Lines         = lines.Select(l => new {
        Description = (string)l.description,
        Quantity    = l.quantity,
        UnitPrice   = l.unit_price,
        LineTotal   = l.line_total
    }),
    Subtotal      = subtotal,
    VatRate       = (int)(vatRate * 100),
    Vat           = vat,
    Total         = total
};

Het script geeft een anoniem object terug. Wat je ook teruggeeft wordt de data-context voor het HTML-template, elke property is op naam toegankelijk in Scriban-placeholders.

Waarom al die casts? Db.GetAsync en .ToListAsync() geven dynamic terug, wat Scriban soms verkeerd kan behandelen wanneer een waarde null is of zijn runtime-type niet is wat het template verwacht. Casten naar een concreet type aan de grens (waar je het return-object bouwt) geeft je voorspelbaar gedrag.

Stap 3 - Schrijf het HTML-template (10 min)

Plak in de HTML Template-editor dit (de styling is het grootste deel van de bytes):

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>{{ InvoiceNumber }}</title>
  <style>
    @page { size: A4; margin: 24mm 18mm; }
    body { font: 11pt/1.5 'Inter', sans-serif; color: #1e2838; }
    h1 { font-size: 24pt; margin: 0 0 4pt; letter-spacing: -0.02em; }
    .muted { color: #566277; font-size: 10pt; }
    .row { display: flex; justify-content: space-between; margin-bottom: 24pt; }
    .right { text-align: right; }
    table { width: 100%; border-collapse: collapse; margin: 16pt 0; }
    th { text-align: left; padding: 6pt 4pt; border-bottom: 2px solid #1e2838; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.08em; }
    td { padding: 8pt 4pt; border-bottom: 1px solid #e4e7ed; }
    td.num { text-align: right; font-variant-numeric: tabular-nums; }
    .totals { margin-left: auto; width: 60mm; }
    .totals td { padding: 4pt 4pt; border: none; }
    .totals .grand { border-top: 2px solid #1e2838; font-weight: 700; font-size: 13pt; }
    footer { margin-top: 32pt; font-size: 9pt; color: #8c97aa; text-align: center; }
  </style>
</head>
<body>
  <div class="row">
    <div>
      <h1>Invoice</h1>
      <div class="muted">{{ InvoiceNumber }} · {{ InvoiceDate }}</div>
    </div>
    <div class="right">
      <strong>Bill to</strong><br>
      {{ Company.Name }}<br>
      <span class="muted">{{ Company.Industry }}</span>
    </div>
  </div>

  <div><strong>{{ Deal.Title }}</strong> <span class="muted">· {{ Deal.Stage }}</span></div>

  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th class="num">Qty</th>
        <th class="num">Unit price</th>
        <th class="num">Line total</th>
      </tr>
    </thead>
    <tbody>
      {{ for line in Lines }}
      <tr>
        <td>{{ line.Description }}</td>
        <td class="num">{{ line.Quantity }}</td>
        <td class="num">€ {{ line.UnitPrice }}</td>
        <td class="num">€ {{ line.LineTotal }}</td>
      </tr>
      {{ end }}
    </tbody>
  </table>

  <table class="totals">
    <tr>
      <td>Subtotal</td>
      <td class="num">€ {{ Subtotal }}</td>
    </tr>
    <tr>
      <td>VAT ({{ VatRate }}%)</td>
      <td class="num">€ {{ Vat }}</td>
    </tr>
    <tr class="grand">
      <td>Total</td>
      <td class="num">€ {{ Total }}</td>
    </tr>
  </table>

  <footer>Thank you for your business.</footer>
</body>
</html>

Bekijk het resultaat

Klik op de groene Update-knop (of Create bij een nieuw template) rechtsboven om op te slaan. Het PDF Preview-paneel rechts rendert opnieuw. Een thumbnail-kolom aan de linkerrand van de preview toont pagina 1, pagina 2, etc.; het hoofdpaneel toont de volledige render met een ingebouwde PDF-viewer-werkbalk (zoom, draaien, downloaden, printen, meer). Itereer door de HTML- of Data Script-tab te bewerken en opnieuw op te slaan.

De icoonknoppen rechtsboven in de editor (naast Cancel) laten je de zichtbaarheid van het preview-paneel toggelen en schakelen naar full-screen-editingmodus. Het help (?)-icoon opent een inline cheat-sheet voor Scriban-syntaxis.

PDF Templates-editor: Name- en Description-velden bovenaan, vijf tabs (HTML Template, Data Script, Settings, Params, JSON), Monaco-editor links, altijd-aan-staand PDF Preview-paneel rechts met een thumbnail-kolom en een gerenderde factuur.
PDF Templates-editor met een echt factuur-template. Bovenaan: Name- + Description-velden, plus layout-toggle-iconen / help / Cancel / groene Update rechts. Tabs: HTML Template (hier actief) / Data Script / Settings / Params (met een count-badge) / JSON. Rechterpaneel: altijd-aan-staand PDF Preview met een thumbnail-kolom (pagina 1 geselecteerd, pagina 2 daaronder), een ingebouwde viewer-werkbalk (zoom 47%, draaien, downloaden, printen, etc.), en de gerenderde output.
Dingen die vaak een tweak nodig hebben:
  • Nummerformattering. Decimalen komen terug als ruwe getallen; als je vast 2-decimalen-weergave wilt, formatteer in het datascript (l.line_total.ToString("0.00")) en geef strings terug.
  • Valutasymbool. Hierboven hardcoded als euro, externaliseer naar een parameter of een Branding-instelling als je meerdere valuta's bedient.
  • Paginabreuken. Voor lange regelitemlijsten, voeg page-break-inside: avoid toe aan tr in de CSS zodat een rij niet over pagina's wordt gesplitst.
  • Lettertypes. De renderer heeft Liberation-, DejaVu- en Noto-families. Als je naar Inter verwijst (zoals hierboven) valt het terug op een systeem-sans. Om een merklettertype te embedden, base64-encodeer een .woff2 en inline het via @font-face.

Stap 4 - Genereer de PDF van buiten de app (5 min)

Het template is nu op naam aanroepbaar vanuit elke HTTP-client. Drie endpoints, allemaal onder /api/v1/pdf-templates:

  • POST /api/v1/pdf-templates/{id}/preview - rendert de template op zijn database-ID en geeft application/pdf terug. Gebruikt door het preview-paneel van de editor.
  • POST /api/v1/pdf-templates/generate/{name} - rendert op naam en geeft application/pdf terug (browser-download).
  • POST /api/v1/pdf-templates/generate/{name}/base64 - hetzelfde maar geeft { "data": "<base64>" } terug. Handig wanneer de caller de PDF wil embedden in een andere response-payload.

Vanuit een shell, met een Bearer-token vanuit de adminapp:

curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"deal_id": 1}' \
  https://demo1.archestack.eu/api/v1/pdf-templates/generate/DealInvoice \
  --output invoice.pdf

Vanuit een extern systeem (een Zapier-integratie, een order-bevestigingsflow van een partner), roep hetzelfde endpoint aan met de parameters in de JSON-body. Het platform draait het datascript met die parameters, rendert de HTML, en streamt de PDF terug.

Trial-modus: zowel het generate-endpoint als het {id}/preview-endpoint geven 403 terug in de trial-omgeving, dus noch de externe curl-aanroep noch het preview-paneel van de editor zal een PDF teruggeven totdat je op een betaalde omgeving zit. De template-metadata (datascript, HTML, settings) slaat nog steeds normaal op, het renderen is wat afgeschermd is.

Recap

  • Je hebt het schema uitgebreid met een kindtabel (deal_line) en een Before- trigger gebruikt om een afgeleid veld in sync te houden.
  • Je hebt een PDF-template gebouwd, een datascript dat de juiste rijen ophaalt, een HTML-body die ze rendert met Scriban-placeholders.
  • Je hebt de REST-endpoints geleerd voor het aanroepen van de template van overal, inclusief het preview-endpoint dat de editor zelf aandrijft.

Waar nu naartoe

  • PDF Templates-referentie - dekt het volledige Scriban-oppervlak, font-afhandeling, paginabreuk-trucs, en de Settings-tab van de editor (marges, papierformaat, orientatie).
  • Scheduled Events - roep het PDF generate- endpoint aan vanuit een Script Module en e-mail het resultaat naar de klant op een maandelijks schema.