Pembuatan PDF Faktur ERP: Puppeteer + Handlebars di NestJS

Foto oleh Unsplash
Library PDF murni memerlukan matematika koordinat manual untuk mengatur posisi, font, border tabel, dan perataan kolom, sehingga membangun tata letak faktur yang rapi menjadi menyulitkan. Dengan Puppeteer, faktur ditulis sebagai template HTML menggunakan CSS yang sudah familiar—flexbox, CSS Grid, dan print media query—sehingga hasil render tampak persis seperti pratinjau cetak di browser.
PPN dihitung sebesar 11% dari subtotal sesuai perubahan tarif dari 10% menjadi 11% yang berlaku April 2022 berdasarkan PMK-63/PMK.03/2022. Template Handlebars membuat baris PPN bersifat kondisional sehingga transaksi non-kena-pajak dapat menghilangkannya sepenuhnya. Format Rupiah menggunakan API Intl.NumberFormat dengan locale 'id-ID' dan mata uang 'IDR' untuk menghasilkan format titik-pemisah yang benar (contoh: Rp 1.500.000).
Logo disematkan sebagai data URI base64 langsung di dalam data template Handlebars, bukan melalui referensi path file atau URL. Ini diperlukan karena page.setContent() milik Puppeteer tidak selalu menyelesaikan path relatif atau membuat request jaringan untuk gambar eksternal, yang akan menghasilkan logo rusak di PDF yang dihasilkan.
Cloud Run memerlukan puppeteer-core (tanpa Chromium bawaan) dikombinasikan dengan @sparticuz/chromium, yang menyediakan binary Chromium pre-built yang dioptimalkan untuk lingkungan serverless. Setiap launch browser membutuhkan 1–3 detik, sehingga untuk volume tinggi, pendekatan yang disarankan adalah antrian Cloud Tasks yang memproses pekerjaan secara asinkron dan mengunggah hasilnya ke Cloud Storage, mengembalikan signed URL alih-alih melakukan streaming PDF secara langsung.
PDF yang dihasilkan di-cache di Cloud Storage: request pertama menghasilkan dan menyimpan PDF, sementara request berikutnya langsung mengembalikan signed URL GCS. Cache dibatalkan jika faktur dimodifikasi. Untuk faktur dengan status APPROVED—yang terkunci dari perubahan—PDF dapat di-cache tanpa batas waktu.

Foto oleh Unsplash
Membuat PDF faktur terdengar seperti masalah yang sudah selesai—ada selusin library untuk itu. Tapi dalam konteks bisnis Indonesia, persyaratannya berlapis: format Rupiah, NPWP, perhitungan PPN 11%, dan tata letak merek yang tepat. Setelah mengevaluasi berbagai library, saya memilih Puppeteer—render HTML dengan kontrol CSS penuh, lalu print ke PDF.
Library PDF murni memerlukan matematika koordinat manual untuk posisi, font, border tabel, dan perataan kolom. Dengan Puppeteer, Anda menulis faktur sebagai template HTML menggunakan CSS yang familiar: flexbox, CSS Grid, font loading standar, dan print media query untuk page break.
Handlebars adalah template engine karena logikanya ringan dan mendukung fungsi helper. Template invoice.hbs merender header perusahaan dengan logo tertanam sebagai base64, alamat pelanggan, tabel item baris, section total dengan PPN, dan footer dengan syarat pembayaran.
API Intl.NumberFormat dengan locale 'id-ID' dan mata uang 'IDR' menghasilkan format Rupiah yang benar. PPN dihitung sebesar 11% dari subtotal sesuai PMK-63/PMK.03/2022. Template juga mendukung transaksi non-kena-pajak dengan membuat baris PPN kondisional.
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. Compute totals, tax (PPN 11%) │
│ 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 logo perusahaan sebagai data URI base64 di data template Handlebars, bukan referensi path file atau URL. page.setContent() Puppeteer tidak selalu menyelesaikan path relatif atau membuat request jaringan untuk gambar eksternal.
Menjalankan Puppeteer di Cloud Run memerlukan puppeteer-core (tanpa Chromium bawaan) ditambah @sparticuz/chromium, yang menyediakan binary Chromium pre-built yang dioptimalkan untuk lingkungan serverless.
Saya menggunakan format A4 dengan margin atas/bawah 20mm dan kiri/kanan 15mm—standar untuk dokumen bisnis Indonesia. printBackground harus true untuk merender header tabel berwarna. Media query print CSS menangani page break untuk faktur multi-halaman.
// 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
);
const tax = Math.round(subtotal * 0.11); // PPN 11%
const total = subtotal + tax;
const html = this.template({
invoice,
subtotal,
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();
}
}
}Saya meng-cache PDF yang dihasilkan di Cloud Storage: request pertama menghasilkan dan menyimpan PDF, dan request berikutnya langsung mengembalikan signed URL GCS. Cache dibatalkan jika faktur dimodifikasi.
Setiap launch browser Puppeteer membutuhkan 1-3 detik. Untuk volume tinggi, pertimbangkan antrian Cloud Tasks yang memproses pekerjaan PDF secara asinkron dan mengunggah hasilnya ke Cloud Storage.
Endpoint PDF faktur digunakan kembali oleh modul otomasi email: ketika finance mengklik 'Kirim ke Pelanggan', sistem memanggil service PDF, mendapat buffer, dan melampirkannya ke email Nodemailer.
Perubahan utama yang akan saya buat adalah menggunakan job queue untuk pembuatan PDF dari awal. Ketika tabel item baris tumbuh, pembuatan PDF bisa memakan waktu 4-5 detik yang menekan timeout HTTP. Pendekatan berbasis queue memisahkan pengalaman pengguna dari waktu eksekusi Puppeteer.