Skip to main content
BlogERP

Pembuatan PDF Faktur ERP: Puppeteer + Handlebars di NestJS

Panduan lengkap membangun ekspor PDF faktur di sistem ERP custom menggunakan Puppeteer, template Handlebars, dan NestJS—dengan format Rupiah Indonesia dan perhitungan PPN.

Matthews Wong

Software Engineer

Juni 20259 menit baca
Pembuatan PDF Faktur ERP: Puppeteer + Handlebars di NestJS

Foto oleh Unsplash

Ringkasan Utama

Membuat PDF invoice Indonesia berarti format Rupiah dengan titik sebagai pemisah ribuan, NPWP yang tercantum di dokumen, dan PPN yang dihitung pada tarif berlaku. Merender HTML lewat headless Chromium lebih baik daripada pustaka PDF berbasis koordinat, karena tata letak invoice adalah persoalan dokumen, bukan persoalan menggambar.

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 (12% atas DPP 11/12 untuk sebagian besar barang sejak 2025), dan tata letak merek yang tepat. Setelah mengevaluasi berbagai library, saya memilih Puppeteer—render HTML dengan kontrol CSS penuh, lalu print ke PDF.

Kenapa Puppeteer daripada Library 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.

Desain Template dengan Handlebars

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.

Format Rupiah Indonesia dan PPN

API Intl.NumberFormat dengan locale 'id-ID' dan mata uang 'IDR' menghasilkan format Rupiah yang benar: titik sebagai pemisah ribuan, tanpa desimal untuk nilai bulat (Rp 1.500.000). Sejak 1 Januari 2025, PMK 131/2024 menetapkan PPN 12%: untuk barang selain barang mewah, PPN dikenakan atas DPP Nilai Lain sebesar 11/12 dari subtotal, setara 11% dari subtotal, dan untuk barang yang dikenai PPnBM atas subtotal penuh. Tarif sebelumnya naik dari 10% menjadi 11% pada April 2022 berdasarkan UU 7/2021 tentang Harmonisasi Peraturan Perpajakan. 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. 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 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.

Puppeteer di Lingkungan Cloud Run

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.

Pengaturan Halaman dan CSS Print

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
    );
    // 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 dan Penyimpanan

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.

Mengirim Faktur sebagai Lampiran Email

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.

Yang Akan Saya Lakukan Berbeda

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa memilih Puppeteer dibanding library PDF khusus seperti pdfkit atau pdf-lib?

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.

Bagaimana PPN dihitung dan ditampilkan dalam template faktur?

Sejak 1 Januari 2025, PMK 131/2024 menetapkan PPN 12%: untuk barang dan jasa selain barang mewah, PPN dikenakan atas DPP Nilai Lain sebesar 11/12 dari subtotal, setara 11% dari subtotal, dan untuk barang yang dikenai PPnBM atas subtotal penuh. Kenaikan sebelumnya dari 10% menjadi 11% pada April 2022 berasal dari UU 7/2021 tentang Harmonisasi Peraturan Perpajakan. Template Handlebars membuat baris PPN bersifat kondisional sehingga transaksi non-kena-pajak dapat menghilangkannya sepenuhnya, dan format Rupiah menggunakan API Intl.NumberFormat dengan locale 'id-ID' dan mata uang 'IDR' untuk menghasilkan format titik-pemisah (contoh: Rp 1.500.000).

Bagaimana logo perusahaan bisa tertanam dengan andal di dalam PDF yang dihasilkan?

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.

Apa yang dibutuhkan untuk menjalankan Puppeteer di Cloud Run, dan apa trade-off cold-start-nya?

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.

Bagaimana PDF yang dihasilkan di-cache agar tidak perlu menjalankan Puppeteer berulang kali?

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.