Pembuatan PDF Faktur ERP: Puppeteer + Handlebars di NestJS

Foto oleh Unsplash

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.