Tutoriel · 25 min

Générer des factures PDF

Ce tutoriel ajoute une facture PDF imprimable pour chaque Deal du mini-CRM que vous avez construit dans Construisez votre première application. À la fin, vous aurez un template de facture qui tire la company, les lignes et les totaux d'un deal, rendu en direct dans le panneau d'aperçu de l'éditeur et appelable depuis des systèmes externes via REST.

Durée totale : environ 25 minutes. Vous toucherez aux PDF Templates et étendrez le schéma avec une table de lignes.

Limitation d'essai : le rendu PDF est désactivé dans l'environnement d'essai pour empêcher que le pool partagé de Chromium headless ne soit surchargé. À la fois le panneau d'aperçu en direct de l'éditeur (POST /api/v1/pdf-templates/{id}/preview) et l'endpoint public POST /api/v1/pdf-templates/generate/<name> retournent 403 tant que vous n'êtes pas sur un environnement payant. Vous pouvez toujours créer le template (data script, HTML, settings, paramètres) et l'enregistrer, le panneau d'aperçu affichera le 403 tant que le rendu n'est pas réactivé. Prévoyez de vérifier la sortie rendue sur un environnement non-essai avant de vous y fier.

Prérequis

Vous devriez avoir le mini-CRM de Construisez votre première application déployé : tables company, contact, deal ; leurs Business Entities ; les trois pages. Si vous avez sauté ce tutoriel, le reste de cette page n'aura pas beaucoup de sens.

Étape 1 - Ajoutez les lignes au schéma (5 min)

Une vraie facture a des lignes, pas juste un seul montant. Ajoutez une table deal_line.

  1. Ouvrez Schema Designer -> Add Table -> nommez-la deal_line.
  2. Dans l'onglet Columns du panneau de droite, ajoutez :
    • id · SERIAL, PK (auto)
    • deal_id · INTEGER, requis
    • description · VARCHAR(300), requis
    • quantity · DECIMAL, requis, défaut 1
    • unit_price · DECIMAL, requis, défaut 0
    • line_total · DECIMAL, requis, défaut 0 - dérivé, tenu à jour ci-dessous
  3. Basculez sur l'onglet Relations. Ajoutez une relation : FK column deal_id, Relation type One-to-Many, References table deal, References column id. Cliquez sur Add Relationship.
  4. Cliquez sur Deploy, passez en revue le Generated SQL, cliquez sur Deploy en haut à droite.

Câblez le BE, la page et un petit event

  1. Business Entities -> Create. Entity Name deal_line, Master Table deal_line, Label Column description. Enregistrez.
  2. Ouvrez la page Deals existante dans Page Editor. Dans le formulaire de détail, cliquez sur Add Tab, nommez l'onglet Lines et ajoutez une section RelatedGrid liée à deal_line avec le filtre de jointure deal.id = id de l'enregistrement courant. Enregistrez et republiez.
  3. Business Events -> Create. Rule Name Calc deal_line.line_total. Enabled ON. Business Entity deal_line. Triggers : Before Create + Before Update. Pas de conditions. Action : Execute Script avec le corps :
    Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
    Enregistrez.

Cliquez dans un Deal, basculez sur le nouvel onglet Lines et ajoutez deux ou trois lignes. La colonne line_total devrait se remplir automatiquement à chaque sauvegarde d'une ligne.

Étape 2 - Écrivez le data script du PDF (5 min)

Ouvrez PDF Templates -> cliquez sur + Create. L'éditeur s'ouvre avec un champ Name en haut (tapez DealInvoice), un champ Description et cinq onglets : HTML Template, Data Script, Settings, Params, JSON. Le panneau PDF Preview est toujours visible à droite et se recompose à l'enregistrement.

Basculez sur l'onglet Params -> cliquez sur + Add. Name deal_id, Type int, Required coché. (Le libellé de l'onglet se met à jour en Params (1) une fois que vous en avez défini un.)

Basculez sur l'onglet Data Script, l'éditeur Monaco C# s'ouvre à gauche, avec l'aperçu PDF toujours à droite. Collez :

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

Le script retourne un objet anonyme. Quoi que vous retourniez devient le contexte de données du template HTML, chaque propriété est accessible par nom dans les placeholders Scriban.

Pourquoi tous les casts ? Db.GetAsync et .ToListAsync() retournent dynamic, que Scriban peut parfois mal gérer lorsqu'une valeur est null ou que son type d'exécution n'est pas ce que le template attend. Caster vers un type concret à la frontière (là où vous construisez l'objet de retour) vous donne un comportement prévisible.

Étape 3 - Écrivez le template HTML (10 min)

Dans l'éditeur HTML Template, collez ceci (le style représente la majeure partie des octets) :

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

Voyez le résultat

Cliquez sur le bouton vert Update (ou Create sur un nouveau template) en haut à droite pour enregistrer. Le panneau PDF Preview à droite se recompose. Une colonne de miniatures sur le bord gauche de l'aperçu affiche la page 1, la page 2, etc. ; le panneau principal affiche le rendu complet avec une barre d'outils de visualisation PDF intégrée (zoom, rotation, download, print, etc.). Itérez en éditant l'onglet HTML ou Data Script et en enregistrant à nouveau.

Les boutons-icônes en haut à droite de l'éditeur (à côté de Cancel) permettent de basculer la visibilité du panneau d'aperçu et de passer en mode édition plein écran. L'icône d'aide (?) ouvre un aide-mémoire en ligne pour la syntaxe Scriban.

Éditeur PDF Templates : champs Name et Description en haut, cinq onglets (HTML Template, Data Script, Settings, Params, JSON), éditeur Monaco à gauche, panneau PDF Preview toujours actif à droite avec une colonne de miniatures et une facture rendue.
Éditeur PDF Templates affichant un vrai template de facture. En haut : champs Name + Description, plus icônes de bascule de disposition / aide / Cancel / Update vert à droite. Onglets : HTML Template (actif ici) / Data Script / Settings / Params (avec un badge de compte) / JSON. Panneau de droite : PDF Preview toujours actif avec une colonne de miniatures (page 1 sélectionnée, page 2 en dessous), une barre d'outils de visualisation intégrée (zoom 47%, rotation, download, print, etc.) et la sortie rendue.
Choses qui nécessitent souvent un ajustement :
  • Formatage des nombres. Les décimaux reviennent comme des nombres bruts ; si vous voulez un affichage fixe à 2 décimales, formatez dans le data script (l.line_total.ToString("0.00")) et retournez des chaînes.
  • Symbole monétaire. Codé en dur en € ci-dessus, externalisez vers un paramètre ou un setting Branding si vous servez plusieurs devises.
  • Sauts de page. Pour de longues listes de lignes, ajoutez page-break-inside: avoid à tr dans le CSS pour qu'une ligne ne se coupe pas entre les pages.
  • Polices. Le moteur de rendu a les familles Liberation, DejaVu et Noto. Si vous référencez Inter (comme ci-dessus), cela retombe sur un sans-serif système. Pour embarquer une police de marque, encodez en base64 un .woff2 et inlinez-le via @font-face.

Étape 4 - Générez le PDF depuis l'extérieur de l'application (5 min)

Le template est maintenant invocable par son nom depuis n'importe quel client HTTP. Trois endpoints, tous sous /api/v1/pdf-templates :

  • POST /api/v1/pdf-templates/{id}/preview - rend le template par son ID de base de données et retourne application/pdf. Utilisé par le panneau d'aperçu de l'éditeur.
  • POST /api/v1/pdf-templates/generate/{name} - rend par nom et retourne application/pdf (téléchargement navigateur).
  • POST /api/v1/pdf-templates/generate/{name}/base64 - identique mais retourne { "data": "<base64>" }. Utile lorsque l'appelant veut embarquer le PDF dans un autre payload de réponse.

Depuis un shell, avec un Bearer token de l'application admin :

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

Depuis un système externe (une intégration Zapier, le flux de confirmation de commande d'un partenaire), appelez le même endpoint avec les paramètres dans le corps JSON. La plateforme exécute le data script avec ces paramètres, rend le HTML et renvoie le PDF en streaming.

Mode essai : à la fois l'endpoint generate et l'endpoint {id}/preview retournent 403 dans l'environnement d'essai, donc ni l'appel curl externe ni le panneau d'aperçu de l'éditeur ne retourneront un PDF tant que vous n'êtes pas sur un environnement payant. Les métadonnées du template (data script, HTML, settings) s'enregistrent normalement, c'est le rendu qui est bloqué.

Récap

  • Vous avez étendu le schéma avec une table enfant (deal_line) et utilisé un trigger Before pour garder un champ dérivé en sync.
  • Vous avez construit un PDF template, un data script qui tire les bonnes lignes, un corps HTML qui les rend avec des placeholders Scriban.
  • Vous avez appris les endpoints REST pour invoquer le template depuis n'importe où, y compris l'endpoint preview qui propulse l'éditeur lui-même.

Pour aller plus loin

  • Référence PDF Templates - couvre la surface Scriban complète, la gestion des polices, les astuces de saut de page et l'onglet Settings de l'éditeur (marges, format de papier, orientation).
  • Scheduled Events - appelez l'endpoint generate PDF depuis un Script Module et envoyez le résultat par email au customer sur un calendrier mensuel.