Tutorial · 25 Min

PDF-Rechnungen erzeugen

Dieses Tutorial fügt eine druckbare PDF-Rechnung für jeden Deal in dem Mini-CRM hinzu, das du in Baue deine erste App gebaut hast. Am Ende wirst du eine Rechnungsvorlage haben, die Company, Positionen und Summen eines Deals zieht, live in der Vorschau-Pane des Editors gerendert und über REST aus externen Systemen aufrufbar.

Gesamtdauer: ~25 Minuten. Du wirst PDF Templates berühren und das Schema um eine Positions-Tabelle erweitern.

Trial-Einschränkung: PDF-Rendering ist innerhalb der Trial-Umgebung deaktiviert, um den geteilten Headless-Chromium-Pool vor Überlastung zu schützen. Sowohl die Live-Vorschau-Pane des Editors (POST /api/v1/pdf-templates/{id}/preview) als auch der öffentliche POST /api/v1/pdf-templates/generate/<name>-Endpunkt geben 403 zurück, bis du in einer bezahlten Umgebung bist. Du kannst das Template trotzdem bearbeiten (Data Script, HTML, Settings, Parameter) und speichern, die Vorschau-Pane wird den 403 zeigen, bis das Rendering wieder aktiviert ist. Plane, die gerenderte Ausgabe in einer Nicht-Trial-Umgebung zu verifizieren, bevor du dich darauf verlässt.

Voraussetzungen

Du solltest das Mini-CRM aus Baue deine erste App deployt haben: company-, contact-, deal-Tabellen; ihre Business Entities; die drei Pages. Wenn du dieses Tutorial übersprungen hast, wird der Rest dieser Seite wenig Sinn ergeben.

Schritt 1 - Positionen zum Schema hinzufügen (5 Min)

Eine echte Rechnung hat Positionen, nicht nur einen einzigen Betrag. Füge eine deal_line-Tabelle hinzu.

  1. Öffne Schema DesignerAdd Table → benenne sie deal_line.
  2. Im Columns-Tab des rechten Panels füge hinzu:
    • id · SERIAL, PK (auto)
    • deal_id · INTEGER, erforderlich
    • description · VARCHAR(300), erforderlich
    • quantity · DECIMAL, erforderlich, Default 1
    • unit_price · DECIMAL, erforderlich, Default 0
    • line_total · DECIMAL, erforderlich, Default 0 - abgeleitet, wird unten synchron gehalten
  3. Wechsle zum Relations-Tab. Füge eine Beziehung hinzu: FK-Spalte deal_id, Relation type One-to-Many, References table deal, References column id. Klicke auf Add Relationship.
  4. Klicke auf Deploy, prüfe das Generated SQL, klicke oben rechts auf Deploy.

BE, Page und ein winziges Event verdrahten

  1. Business EntitiesCreate. Entity Name deal_line, Master Table deal_line, Label Column description. Speichern.
  2. Öffne die bestehende Deals-Page im Page Editor. Im Detailformular klicke auf Add Tab, benenne den Tab Lines und füge eine RelatedGrid-Sektion hinzu, gebunden an deal_line mit dem Join-Filter deal.id = current record's id. Speichern und neu veröffentlichen.
  3. Business EventsCreate. Rule Name Calc deal_line.line_total. Enabled AN. Business Entity deal_line. Triggers: Before Create + Before Update. Keine Conditions. Action: Execute Script mit Body:
    Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
    Speichern.

Klicke in einen Deal, wechsle zum neuen Lines-Tab und füge zwei oder drei Positionen hinzu. Die line_total-Spalte sollte sich jedes Mal automatisch füllen, wenn du eine Position speicherst.

Schritt 2 - Das PDF-Data-Script schreiben (5 Min)

Öffne PDF Templates → klicke auf + Create. Der Editor öffnet sich mit einem Name-Feld oben (tippe DealInvoice), einem Description-Feld und fünf Tabs: HTML Template, Data Script, Settings, Params, JSON. Die PDF Preview-Pane ist immer rechts sichtbar und rendert neu, wenn du speicherst.

Wechsle zum Params-Tab → klicke auf + Add. Name deal_id, Type int, Required angekreuzt. (Das Tab-Label aktualisiert sich zu Params (1), sobald du einen definiert hast.)

Wechsle zum Data Script-Tab, der Monaco C#-Editor öffnet sich auf der linken Seite, mit der PDF-Vorschau weiterhin rechts. Füge ein:

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
};

Das Skript gibt ein anonymes Objekt zurück. Was immer du zurückgibst, wird zum Datenkontext für das HTML-Template, jede Property ist per Name in Scriban-Platzhaltern zugreifbar.

Warum all die Casts? Db.GetAsync und .ToListAsync() geben dynamic zurück, was Scriban manchmal falsch handhabt, wenn ein Wert null ist oder sein Laufzeittyp nicht dem entspricht, was das Template erwartet. Casts auf einen konkreten Typ an der Grenze (wo du das Rückgabeobjekt baust) gibt dir vorhersehbares Verhalten.

Schritt 3 - Das HTML-Template schreiben (10 Min)

Füge im HTML Template-Editor das Folgende ein (das Styling macht den Großteil der Bytes aus):

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

Das Ergebnis sehen

Klicke oben rechts auf den grünen Update-Button (oder Create bei einem neuen Template), um zu speichern. Die PDF Preview-Pane rechts rendert neu. Eine Thumbnail-Spalte am linken Rand der Vorschau zeigt Seite 1, Seite 2, etc.; die Hauptpane zeigt das volle Rendering mit einer eingebauten PDF-Viewer-Symbolleiste (Zoom, Drehen, Download, Druck, mehr). Iteriere, indem du den HTML- oder Data Script-Tab bearbeitest und erneut speicherst.

Die Icon-Buttons oben rechts im Editor (neben Cancel) lassen dich die Sichtbarkeit der Vorschau-Pane umschalten und in den Vollbild-Bearbeitungsmodus wechseln. Das Hilfe-Symbol (?) öffnet einen inline Spickzettel für Scriban-Syntax.

PDF Templates-Editor: Name- und Description-Felder oben, fünf Tabs (HTML Template, Data Script, Settings, Params, JSON), Monaco-Editor links, immersichtbare PDF Preview-Pane rechts mit einer Thumbnail-Spalte und einer gerenderten Rechnung.
PDF Templates-Editor mit einer echten Rechnungsvorlage. Oben: Name- + Description-Felder, plus Layout-Umschalt-Icons / Hilfe / Cancel / grünes Update rechts. Tabs: HTML Template (hier aktiv) / Data Script / Settings / Params (mit einem Zähler-Badge) / JSON. Rechte Pane: immersichtbare PDF Preview mit einer Thumbnail-Spalte (Seite 1 ausgewählt, Seite 2 darunter), einer eingebauten Viewer-Symbolleiste (Zoom 47%, Drehen, Download, Druck, etc.) und der gerenderten Ausgabe.
Dinge, die oft einer Anpassung bedürfen:
  • Zahlenformatierung. Dezimale kommen als rohe Zahlen zurück; wenn du feste 2-Dezimal-Anzeige willst, formatiere im Data-Script (l.line_total.ToString("0.00")) und gib Strings zurück.
  • Währungssymbol. Oben hartcodiert als €, lagere zu einem Parameter oder einer Branding-Einstellung aus, wenn du mehrere Währungen bedienst.
  • Seitenumbrüche. Für lange Positionslisten füge page-break-inside: avoid zu tr im CSS hinzu, damit eine Zeile nicht über Seiten gespalten wird.
  • Schriftarten. Der Renderer hat die Familien Liberation, DejaVu und Noto. Wenn du Inter referenzierst (wie oben), fällt es auf eine System-Sans zurück. Um eine Markenschrift einzubetten, base64-kodiere eine .woff2 und inline sie über @font-face.

Schritt 4 - Das PDF von außerhalb der App generieren (5 Min)

Das Template ist jetzt per Name von jedem HTTP-Client aufrufbar. Drei Endpunkte, alle unter /api/v1/pdf-templates:

  • POST /api/v1/pdf-templates/{id}/preview - rendert das Template nach seiner Datenbank-ID und gibt application/pdf zurück. Wird von der Vorschau-Pane des Editors genutzt.
  • POST /api/v1/pdf-templates/generate/{name} - rendert nach Name und gibt application/pdf zurück (Browser-Download).
  • POST /api/v1/pdf-templates/generate/{name}/base64 - gleich, gibt aber { "data": "<base64>" } zurück. Nützlich, wenn der Aufrufer das PDF in eine andere Antwort-Payload einbetten will.

Aus einer Shell, mit einem Bearer-Token aus der Admin-App:

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

Aus einem externen System (einer Zapier-Integration, dem Auftragsbestätigungs-Flow eines Partners) rufst du denselben Endpunkt mit den Parametern im JSON-Body auf. Die Plattform führt das Data-Script mit diesen Parametern aus, rendert das HTML und streamt das PDF zurück.

Trial-Modus: Sowohl der generate-Endpunkt als auch der {id}/preview-Endpunkt geben in der Trial-Umgebung 403 zurück, sodass weder der externe curl-Aufruf noch die Vorschau-Pane des Editors ein PDF zurückgeben werden, bis du in einer bezahlten Umgebung bist. Die Template-Metadaten (Data Script, HTML, Settings) speichern weiterhin normal, das Rendering ist das, was gesperrt ist.

Recap

  • Du hast das Schema um eine Kindtabelle (deal_line) erweitert und einen Before-Trigger genutzt, um ein abgeleitetes Feld synchron zu halten.
  • Du hast ein PDF-Template gebaut, ein Data-Script, das die richtigen Zeilen zieht, ein HTML-Body, der sie mit Scriban-Platzhaltern rendert.
  • Du hast die REST-Endpunkte gelernt, um das Template von überall aufzurufen, inklusive des Preview-Endpunkts, der den Editor selbst antreibt.

Wie es weitergeht

  • PDF Templates Reference - deckt die volle Scriban-Oberfläche ab, Schriftarten-Handling, Seitenumbruch-Tricks und den Settings-Tab des Editors (Ränder, Papierformat, Orientierung).
  • Scheduled Events - rufe den PDF-Generate-Endpunkt aus einem Script Module auf und maile das Ergebnis monatlich an den Kunden.