Skip to main content
BlogERP

ERP Invoice PDF Generation: Puppeteer + Handlebars in NestJS

A complete walkthrough of building invoice PDF export in a custom ERP system using Puppeteer, Handlebars templates, and NestJS—with Indonesian Rupiah formatting and PPN tax calculation.

Matthews Wong

Software Engineer

June 20259 min read
ERP Invoice PDF Generation: Puppeteer + Handlebars in NestJS

Photo by Unsplash

Key Takeaway

Generating Indonesian invoice PDFs means Rupiah formatting with dots as thousand separators, an NPWP tax ID on the document, and PPN calculated at the current rate. Rendering HTML through headless Chromium beats coordinate-based PDF libraries here, because an invoice layout is a document problem rather than a drawing problem.

Generating PDF invoices sounds like a solved problem—there are a dozen libraries for it. But in an Indonesian business context, the requirements layer on: the invoice must display amounts in Rupiah format (Rp 1.500.000, not 1,500,000), include the company's NPWP tax ID, compute PPN (Pajak Pertambahan Nilai, Indonesia's VAT, charged at 12% on an 11/12 tax base for most goods since 2025) correctly, and match the company's brand layout exactly. After evaluating pdf-lib, pdfkit, and jsPDF, I chose Puppeteer—render HTML with full CSS control, then print to PDF.

Why Puppeteer Over a PDF Library?

Pure PDF libraries (pdfkit, pdf-lib) give you programmatic control over every element, but building a visually polished invoice layout in code is painful—positioning, fonts, table borders, and column alignment all require manual coordinate math. With Puppeteer, you write the invoice as an HTML template using familiar CSS: flexbox for the header, CSS Grid for the line items table, standard font loading, and print media queries for page breaks. The rendered output looks exactly like a browser print preview.

Template Design with Handlebars

Handlebars is the templating engine because it's logic-light, fast to compile, and supports helper functions for formatters. The invoice template (invoice.hbs) renders the company header with logo (embedded as base64), the customer billing address, a table of line items, a totals section with subtotal, PPN amount, and grand total, and a footer with payment terms and bank account details.

Indonesian Rupiah and PPN Formatting

The Intl.NumberFormat API with locale 'id-ID' and currency 'IDR' produces the correct Rupiah format: periods as thousand separators, no decimal places for whole amounts (Rp 1.500.000). Since 1 January 2025, PMK 131/2024 sets PPN at 12%: for goods other than luxury goods it is charged on a DPP Nilai Lain of 11/12 of the subtotal, which comes to 11% of the subtotal, and for goods subject to PPnBM on the full subtotal. The rate had moved from 10% to 11% in April 2022 under UU 7/2021 on harmonising tax regulations. The template also supports non-taxable transactions by making the PPN row conditional.

ERP Invoice PDF Generation Pipeline
  ─────────────────────────────────────

  ┌─────────────────┐
  │  API Request    │  GET /invoices/:id/pdf
  │  (NestJS route) │
  └────────┬────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  Invoice Service                    │
  │  1. Fetch invoice + line items      │
  │  2. Fetch company branding config   │
  │  3. Totals, PPN = 12% × 11/12 × DPP │
  │  4. Format currency (Rp format)     │
  └────────┬────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  Template Engine (Handlebars)       │
  │  invoice.hbs → HTML string          │
  │  - Company logo (base64 embedded)   │
  │  - Line items table                 │
  │  - Totals + PPN calculation         │
  │  - Payment terms, bank account      │
  └────────┬────────────────────────────┘
           │
           ▼
  ┌─────────────────────────────────────┐
  │  Puppeteer / @sparticuz/chromium    │
  │  page.setContent(html)              │
  │  page.pdf({ format: 'A4',           │
  │    printBackground: true })         │
  └────────┬────────────────────────────┘
           │
           ▼
  Buffer → res.setHeader('Content-Type', 'application/pdf')
        → res.send(pdfBuffer)   (or upload to GCS)

Embed your company logo as a base64-encoded data URI in the Handlebars template data rather than referencing it via a file path or URL. Puppeteer's page.setContent() doesn't always resolve relative paths or make network requests for external images, which can result in broken logos in the generated PDF.

Puppeteer in a Cloud Run Environment

Running Puppeteer on Cloud Run requires puppeteer-core (no bundled Chromium) plus @sparticuz/chromium, which provides a pre-built Chromium binary optimized for serverless environments. The chromium.executablePath() function returns the correct binary path whether you're running locally or in the container.

Page Settings and Print CSS

I use A4 format with 20mm top/bottom margins and 15mm left/right margins—standard for Indonesian business documents. The printBackground option must be true to render colored table headers. For multi-page invoices, CSS print media queries handle page breaks: table headers repeat on each page using thead, and page-break-inside: avoid on each table row prevents line items from splitting across pages.

// invoice-pdf.service.ts (NestJS)
import puppeteer from 'puppeteer-core';
import chromium from '@sparticuz/chromium';
import Handlebars from 'handlebars';
import { readFileSync } from 'fs';
import { join } from 'path';

@Injectable()
export class InvoicePdfService {
  private template: HandlebarsTemplateDelegate;

  constructor(private invoiceRepo: InvoiceRepository) {
    const templatePath = join(__dirname, 'templates', 'invoice.hbs');
    const source = readFileSync(templatePath, 'utf-8');
    this.template = Handlebars.compile(source);

    // Register Indonesian Rupiah formatter
    Handlebars.registerHelper('rupiah', (value: number) =>
      new Intl.NumberFormat('id-ID', {
        style: 'currency',
        currency: 'IDR',
        minimumFractionDigits: 0,
      }).format(value)
    );
  }

  async generatePdf(invoiceId: string): Promise<Buffer> {
    const invoice = await this.invoiceRepo.findWithLineItems(invoiceId);

    const subtotal = invoice.lineItems.reduce(
      (sum, item) => sum + item.qty * item.unitPrice, 0
    );
    // PMK 131/2024 (from 1 Jan 2025): PPN 12% on a DPP Nilai Lain of 11/12
    // of the subtotal for non-luxury goods, 12% of the full subtotal for
    // goods subject to PPnBM. Round to whole rupiah (0.50 and above up), as
    // PER-11/PJ/2025 Art. 129 does for the Faktur Pajak, so the two match.
    const taxBase = invoice.isLuxuryGoods
      ? subtotal
      : Math.round((subtotal * 11) / 12);
    const tax = Math.round((taxBase * 12) / 100);
    const total = subtotal + tax;

    const html = this.template({
      invoice,
      subtotal,
      taxBase,
      tax,
      total,
      printDate: new Date().toLocaleDateString('id-ID', {
        day: 'numeric', month: 'long', year: 'numeric'
      }),
    });

    const browser = await puppeteer.launch({
      args: chromium.args,
      executablePath: await chromium.executablePath(),
      headless: chromium.headless,
    });

    try {
      const page = await browser.newPage();
      await page.setContent(html, { waitUntil: 'networkidle0' });
      const pdf = await page.pdf({
        format: 'A4',
        printBackground: true,
        margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
      });
      return Buffer.from(pdf);
    } finally {
      await browser.close();
    }
  }
}

Caching and Storage

Regenerating a PDF on every download request is wasteful if the invoice hasn't changed. I cache generated PDFs in Cloud Storage: the first request generates and stores the PDF, and subsequent requests return the GCS signed URL directly. The cache is invalidated if the invoice is modified. For invoices in APPROVED status (locked from modification), the PDF can be cached indefinitely.

Each Puppeteer browser launch takes 1-3 seconds. For high-volume PDF generation, consider a dedicated PDF service or a Cloud Tasks queue that processes PDF jobs asynchronously and uploads the result to Cloud Storage, returning a download URL instead of streaming the PDF directly.

Sending the Invoice as an Email Attachment

The invoice PDF endpoint is reused by the email automation module: when finance clicks 'Send to Customer', the system calls the PDF service, gets the buffer, and attaches it to a Nodemailer email. The email template is also a Handlebars template. This means the finance team never needs to download the PDF and manually attach it; the ERP handles the full send workflow.

What I'd Do Differently

The main change I'd make is to use a job queue for PDF generation from the start rather than the synchronous HTTP response approach. When the line item table grows (some invoices have 50+ lines), PDF generation can take 4-5 seconds, which strains the HTTP timeout. A queue-based approach—request PDF generation, get a job ID, poll or webhook when ready—decouples the user experience from Puppeteer's execution time.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Why use Puppeteer instead of a dedicated PDF library like pdfkit or pdf-lib?

Pure PDF libraries require manual coordinate math for positioning, fonts, table borders, and column alignment, which makes building a polished invoice layout painful. Puppeteer lets you write the invoice as an HTML template using familiar CSS—flexbox, CSS Grid, and print media queries—so the rendered output looks exactly like a browser print preview.

How is PPN (Indonesian VAT) calculated and displayed in the invoice template?

Since 1 January 2025, PMK 131/2024 sets PPN at 12%: for goods and services other than luxury goods it is charged on a DPP Nilai Lain of 11/12 of the subtotal, which works out to 11% of the subtotal, and for goods subject to PPnBM on the full subtotal. The earlier move from 10% to 11% in April 2022 came from UU 7/2021 on harmonising tax regulations. The Handlebars template makes the PPN row conditional, so non-taxable transactions can omit it entirely, and Rupiah formatting uses the Intl.NumberFormat API with locale 'id-ID' and currency 'IDR' to produce the period-separated format (e.g., Rp 1.500.000).

How is the company logo reliably embedded in the generated PDF?

The logo is embedded as a base64-encoded data URI directly in the Handlebars template data, rather than referenced via a file path or URL. This is necessary because Puppeteer's page.setContent() does not always resolve relative paths or make network requests for external images, which would result in a broken logo in the generated PDF.

What is required to run Puppeteer on Cloud Run, and what is the cold-start trade-off?

Cloud Run requires puppeteer-core (without a bundled Chromium) combined with @sparticuz/chromium, which provides a pre-built Chromium binary optimized for serverless environments. Each browser launch takes 1–3 seconds, so for high-volume PDF generation the recommended approach is a Cloud Tasks queue that processes jobs asynchronously and uploads the result to Cloud Storage, returning a signed URL instead of streaming the PDF directly.

How are generated PDFs cached to avoid redundant Puppeteer runs?

Generated PDFs are cached in Cloud Storage: the first request generates and stores the PDF, and subsequent requests return the GCS signed URL directly. The cache is invalidated if the invoice is modified. For invoices in APPROVED status—which are locked from modification—the PDF can be cached indefinitely.