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.
Sources & Further Reading
- Puppeteer — API Reference — https://pptr.dev/api
- Handlebars.js — Getting Started — https://handlebarsjs.com/guide/
- Sparticuz/chromium — Chromium for Serverless — https://github.com/Sparticuz/chromium
- Kemenkeu JDIH — PMK No. 131 Tahun 2024 on PPN treatment — https://jdih.kemenkeu.go.id/dok/pmk-131-tahun-2024
- DJP — PER-11/PJ/2025, Article 129 on rounding DPP and PPN to whole rupiah — https://www.pajak.go.id/sites/default/files/lampiran/PER-11_PJ_2025_0.pdf

