Tutorial · 25 min

Genera facturas en PDF

Este tutorial añade una factura en PDF imprimible para cada Deal en el mini-CRM que construiste en Construye tu primera aplicación. Al final tendrás una plantilla de factura que extrae la empresa de una deal, las líneas y los totales, renderizada en vivo en el panel de vista previa del editor e invocable desde sistemas externos vía REST.

Tiempo total: ~25 minutos. Tocarás PDF Templates y extenderás el esquema con una tabla de líneas.

Limitación de la prueba: el renderizado de PDF está desactivado dentro del entorno de prueba para evitar que el pool compartido de Chromium headless se sobrecargue. Tanto el panel de vista previa en vivo del editor (POST /api/v1/pdf-templates/{id}/preview) como el endpoint público POST /api/v1/pdf-templates/generate/<name> devuelven 403 hasta que estés en un entorno de pago. Puedes seguir creando la plantilla (data script, HTML, ajustes, parámetros) y guardarla, el panel de vista previa mostrará el 403 hasta que se reactive el renderizado. Planifica verificar la salida renderizada en un entorno no de prueba antes de confiar en ella.

Prerrequisitos

Deberías tener el mini-CRM de Construye tu primera aplicación desplegado: tablas company, contact, deal; sus Business Entities; las tres páginas. Si te saltaste ese tutorial, el resto de esta página no tendrá mucho sentido.

Paso 1 - Añade líneas al esquema (5 min)

Una factura real tiene líneas, no solo un único importe. Añade una tabla deal_line.

  1. Abre Schema Designer -> Add Table -> nómbrala deal_line.
  2. En la pestaña Columns del panel derecho, añade:
    • id · SERIAL, PK (auto)
    • deal_id · INTEGER, obligatorio
    • description · VARCHAR(300), obligatorio
    • quantity · DECIMAL, obligatorio, default 1
    • unit_price · DECIMAL, obligatorio, default 0
    • line_total · DECIMAL, obligatorio, default 0 - derivado, se mantiene sincronizado abajo
  3. Cambia a la pestaña Relations. Añade una relación: columna FK deal_id, Relation type One-to-Many, References table deal, References column id. Haz clic en Add Relationship.
  4. Haz clic en Deploy, revisa el Generated SQL, haz clic en Deploy arriba a la derecha.

Conecta la BE, la página y un pequeño evento

  1. Business Entities -> Create. Entity Name deal_line, Master Table deal_line, Label Column description. Guarda.
  2. Abre la página Deals existente en Page Editor. En el formulario de detalle, haz clic en Add Tab, nombra la pestaña Lines, y añade una sección RelatedGrid vinculada a deal_line con el filtro de join deal.id = id del registro actual. Guarda y vuelve a publicar.
  3. Business Events -> Create. Rule Name Calc deal_line.line_total. Enabled activado. Business Entity deal_line. Triggers: Before Create + Before Update. Sin condiciones. Acción: Execute Script con cuerpo:
    Entity.line_total = (decimal)(Entity.quantity ?? 0) * (decimal)(Entity.unit_price ?? 0);
    Guarda.

Haz clic en una Deal, cambia a la nueva pestaña Lines y añade dos o tres líneas. La columna line_total debería rellenarse automáticamente cada vez que guardes una línea.

Paso 2 - Escribe el data script del PDF (5 min)

Abre PDF Templates -> haz clic en + Create. El editor se abre con un campo Name arriba (escribe DealInvoice), un campo Description y cinco pestañas: HTML Template, Data Script, Settings, Params, JSON. El panel PDF Preview es siempre visible a la derecha y se vuelve a renderizar cuando guardas.

Cambia a la pestaña Params -> haz clic en + Add. Name deal_id, Type int, Required marcado. (La etiqueta de la pestaña se actualiza a Params (1) una vez has definido uno.)

Cambia a la pestaña Data Script, el editor Monaco C# se abre en la izquierda, con la vista previa del PDF aún a la derecha. Pega:

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

El script devuelve un objeto anónimo. Lo que devuelvas se convierte en el contexto de datos para la plantilla HTML, cada propiedad es accesible por nombre en los marcadores Scriban.

¿Por qué todos los casts? Db.GetAsync y .ToListAsync() devuelven dynamic, lo que Scriban a veces puede manejar mal cuando un valor es null o su tipo en tiempo de ejecución no es lo que la plantilla espera. Hacer cast a un tipo concreto en el límite (donde construyes el objeto de retorno) te da un comportamiento predecible.

Paso 3 - Escribe la plantilla HTML (10 min)

En el editor HTML Template, pega esto (los estilos son el grueso de los 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>

Mira el resultado

Haz clic en el botón verde Update (o Create en una plantilla nueva) arriba a la derecha para guardar. El panel PDF Preview a la derecha se vuelve a renderizar. Una columna de miniaturas en el borde izquierdo de la vista previa muestra la página 1, la página 2, etc.; el panel principal muestra el renderizado completo con una barra de herramientas de visor PDF integrada (zoom, rotar, descargar, imprimir, más). Itera editando la pestaña HTML o Data Script y guardando de nuevo.

Los botones con icono en la parte superior derecha del editor (junto a Cancel) te permiten conmutar la visibilidad del panel de vista previa y cambiar al modo de edición a pantalla completa. El icono de ayuda (?) abre una chuleta en línea para la sintaxis Scriban.

Editor PDF Templates: campos Name y Description en la parte superior, cinco pestañas (HTML Template, Data Script, Settings, Params, JSON), editor Monaco a la izquierda, panel PDF Preview siempre visible a la derecha con una columna de miniaturas y una factura renderizada.
Editor PDF Templates mostrando una plantilla de factura real. Arriba: campos Name + Description, más iconos de conmutación de diseño / ayuda / Cancel / Update verde a la derecha. Pestañas: HTML Template (activa aquí) / Data Script / Settings / Params (con una insignia de recuento) / JSON. Panel derecho: PDF Preview siempre visible con una columna de miniaturas (página 1 seleccionada, página 2 debajo), una barra de herramientas de visor integrada (zoom 47%, rotar, descargar, imprimir, etc.) y la salida renderizada.
Cosas que a menudo necesitan un ajuste:
  • Formato numérico. Los decimales vuelven como números crudos; si quieres una visualización fija de 2 decimales, formatea en el data script (l.line_total.ToString("0.00")) y devuelve strings.
  • Símbolo de moneda. Codificado como € arriba, externalízalo a un parámetro o a un ajuste de Branding si sirves múltiples monedas.
  • Saltos de página. Para listas de líneas largas, añade page-break-inside: avoid a tr en el CSS para que una fila no se parta entre páginas.
  • Fuentes. El renderer tiene las familias Liberation, DejaVu y Noto. Si referencias Inter (como arriba) cae a una sans del sistema. Para embeber una fuente de marca, codifica en base64 un .woff2 e inclúyelo vía @font-face.

Paso 4 - Genera el PDF desde fuera de la aplicación (5 min)

La plantilla ya es invocable por nombre desde cualquier cliente HTTP. Tres endpoints, todos bajo /api/v1/pdf-templates:

  • POST /api/v1/pdf-templates/{id}/preview - renderiza la plantilla por su ID de base de datos y devuelve application/pdf. Usado por el panel de vista previa del editor.
  • POST /api/v1/pdf-templates/generate/{name} - renderiza por nombre y devuelve application/pdf (descarga del navegador).
  • POST /api/v1/pdf-templates/generate/{name}/base64 - igual pero devuelve { "data": "<base64>" }. Útil cuando el llamador quiere embeber el PDF en otro payload de respuesta.

Desde un shell, con un token Bearer de la aplicación de administración:

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

Desde un sistema externo (una integración Zapier, el flujo de confirmación de pedido de un socio), llama al mismo endpoint con los parámetros en el cuerpo JSON. La plataforma ejecuta el data script con esos parámetros, renderiza el HTML y transmite el PDF de vuelta.

Modo de prueba: tanto el endpoint generate como el endpoint {id}/preview devuelven 403 en el entorno de prueba, así que ni la llamada externa curl ni el panel de vista previa del editor devolverán un PDF hasta que estés en un entorno de pago. Los metadatos de la plantilla (data script, HTML, ajustes) siguen guardándose con normalidad, lo que está restringido es el renderizado.

Resumen

  • Extendiste el esquema con una tabla hija (deal_line) y usaste un trigger Before para mantener un campo derivado sincronizado.
  • Construiste una plantilla PDF, un data script que extrae las filas correctas, un cuerpo HTML que las renderiza con marcadores Scriban.
  • Aprendiste los endpoints REST para invocar la plantilla desde cualquier lugar, incluido el endpoint de vista previa que alimenta al propio editor.

Hacia dónde ir después

  • Referencia de PDF Templates - cubre la superficie Scriban completa, el manejo de fuentes, trucos de saltos de página y la pestaña Settings del editor (márgenes, formato de papel, orientación).
  • Scheduled Events - llama al endpoint generate del PDF desde un Script Module y envía el resultado por email al cliente con un horario mensual.