Lewati ke konten
Skematik

Cara Membuat Invoice PDF Otomatis dari Google Spreadsheet (Tanpa Aplikasi Berbayar)

8 menit baca · Diperbarui 2026-07-15

Tiap ada order masuk, kamu buka file invoice, ganti nama, ganti barang, ganti total, ganti tanggal, export PDF, baru kirim. Sepuluh order sehari berarti sepuluh kali ritual yang sama, dan pasti ada satu nota yang totalnya salah ketik atau nomornya kembar. Padahal data ordernya sudah rapi ngendon di Google Sheets.

Jadi kenapa bukan sheet itu yang bikin notanya sendiri. Satu tombol, satu baris data berubah jadi PDF rapi lengkap logo, terbilang, dan format rupiah, lalu langsung nyangkut ke WhatsApp atau email pelanggan. Gratis, cuma modal Apps Script bawaan Google. Di bawah ini alur lengkapnya plus kode yang tinggal tempel.

Gambaran alurnya dulu

Sebelum nulis kode, kunci di kepala dulu jalur datanya biar nggak nyasar:

  1. Satu baris di sheet Data sama dengan satu transaksi: nama pelanggan, HP, email, total, tanggal.
  2. Script baca baris itu, ubah jadi objek JavaScript yang gampang dioper ke mana-mana.
  3. Objek disuntik ke template HTML, di-render jadi PDF, disimpan ke folder Drive.
  4. URL PDF-nya dikirim ke pelanggan lewat WhatsApp, atau dilampirkan langsung via email.
  5. Semua itu dipicu satu tombol lewat web app, biar orang non-teknis pun bisa pakai.

Lima langkah. Kita bongkar satu-satu, mulai dari nyiapin sheetnya.

Siapkan sheet data dan template

Bikin satu sheet bernama Data. Baris pertama adalah header, dan nama header ini nanti jadi key objek, jadi tulis yang konsisten tanpa spasi aneh. Contoh kolom minimal:

  • nomor, tanggal, nama, hp, email
  • item, qty, harga (kalau satu invoice satu barang)
  • total (boleh diisi formula =qty*harga)

Satu catatan penting sejak awal: set kolom hp ke format Teks (Format > Number > Plain text) sebelum diisi. Kalau dibiarkan sebagai angka, '081234...' bakal kehilangan nol depannya jadi 81234..., dan itu bikin link WhatsApp-nya salah. Nanti kita bahas lagi di bagian kirim.

Kalau satu invoice bisa banyak barang, pisah jadi sheet Items dengan kolom nomor (buat nyambungin ke invoice), item, qty, harga. Nanti script tinggal filter baris yang nomornya cocok. Untuk artikel ini kita pakai model sederhana dulu: satu baris, satu nota. Gampang di-upgrade belakangan.

Soal template, kita nggak pakai layout sheet yang diprint. Terlalu rewel ngatur page break-nya. Kita render HTML jadi PDF, jadi tampilannya kita kontrol pakai CSS. Tapi CSS-nya harus konservatif, alasannya nanti di bagian PDF. Template HTML-nya taruh sebagai file terpisah di editor Apps Script.

buildInvoice: baris jadi objek siap pakai

Fungsi pertama tugasnya cuma satu: kasih nomor baris, balikin objek data yang bersih. Header dipetakan jadi key otomatis, jadi kalau nanti nambah kolom di sheet, kode ini nggak perlu disentuh.

function buildInvoice(rowIndex) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('Data');
  const lastCol = sheet.getLastColumn();

  const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  const row = sheet.getRange(rowIndex, 1, 1, lastCol).getValues()[0];

  const data = {};
  headers.forEach(function (key, i) {
    data[String(key).trim()] = row[i];
  });

  // Normalisasi yang sering dipakai
  data.total = Number(data.total) || 0;
  data.tanggal = data.tanggal instanceof Date
    ? Utilities.formatDate(data.tanggal, 'Asia/Jakarta', 'dd MMMM yyyy')
    : data.tanggal;

  return data;
}

Perhatikan tanggal. Google Sheets nyimpen tanggal sebagai objek Date, jadi kita format dulu ke string yang enak dibaca sebelum masuk ke PDF. Kalau nggak, yang muncul malah timestamp aneh berzona UTC.

Nomor, rupiah, dan terbilang

Nomor invoice yang unik, angka yang kebaca sebagai rupiah, terbilang buat yang butuh. Ini yang misahin nota kelihatan beres dari yang kelihatan asal. Nomornya kita bikin unik dan urut pakai ScriptProperties, counter yang reset tiap bulan, jadi bentuknya rapi INV/202607/0001.

function nomorBerikutnya() {
  const props = PropertiesService.getScriptProperties();
  const bulan = Utilities.formatDate(new Date(), 'Asia/Jakarta', 'yyyyMM');
  const key = 'seq_' + bulan;

  const seq = Number(props.getProperty(key) || 0) + 1;
  props.setProperty(key, String(seq));

  return 'INV/' + bulan + '/' + Utilities.formatString('%04d', seq);
}

function rupiah(n) {
  // JANGAN pakai toLocaleString('id-ID') di sini, hasilnya nggak bisa dipercaya
  // di Apps Script. Pisah ribuan dihitung manual, nol ketergantungan ke locale.
  const angka = Math.round(Number(n) || 0);
  return 'Rp' + angka.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}

function terbilang(n) {
  n = Math.floor(Math.abs(n));
  const s = ['', 'satu', 'dua', 'tiga', 'empat', 'lima',
             'enam', 'tujuh', 'delapan', 'sembilan', 'sepuluh', 'sebelas'];
  let hasil = '';
  if (n < 12) hasil = s[n];
  else if (n < 20) hasil = terbilang(n - 10) + ' belas';
  else if (n < 100) hasil = terbilang(Math.floor(n / 10)) + ' puluh ' + terbilang(n % 10);
  else if (n < 200) hasil = 'seratus ' + terbilang(n - 100);
  else if (n < 1000) hasil = terbilang(Math.floor(n / 100)) + ' ratus ' + terbilang(n % 100);
  else if (n < 2000) hasil = 'seribu ' + terbilang(n - 1000);
  else if (n < 1000000) hasil = terbilang(Math.floor(n / 1000)) + ' ribu ' + terbilang(n % 1000);
  else if (n < 1000000000) hasil = terbilang(Math.floor(n / 1000000)) + ' juta ' + terbilang(n % 1000000);
  else hasil = terbilang(Math.floor(n / 1000000000)) + ' miliar ' + terbilang(n % 1000000000);

  return hasil.replace(/\s+/g, ' ').trim();
}

Soal rupiah, ini yang bikin banyak orang kejeblos. Godaan pertama pasti Number(n).toLocaleString('id-ID'). Di browser itu jalan, di Apps Script hasilnya ngaco. Runtime V8-nya cuma bawa data Intl/ICU seadanya, argumen locale 'id-ID' sering diabaikan, jadi 1000000 bisa keluar jadi '1,000,000' gaya en-US atau malah '1000000' polos tanpa pemisah. Bukan '1.000.000' yang kamu mau. Bug diam yang bikin semua angka nota salah pisah ribuan. Makanya rupiah() di atas ngitung pemisah ribuannya sendiri lewat regex.

createPdf: render HTML jadi PDF

Ini inti trik-nya. Apps Script bisa mengubah blob HTML jadi PDF cuma dengan satu panggilan getAs. Kita bikin template HTML terpisah (file, bukan string), isi datanya via scriptlet, render jadi teks, lalu konversi. Tapi ada satu hal yang wajib kamu tahu sebelum ngedesain templatenya.

Karena itu header di template pakai satu tabel dua kolom, bukan flexbox. Buat file HTML baru di editor Apps Script, namai invoice, isi seperti ini. Scriptlet <?= ?> itu cara Apps Script nyuntik variabel ke template.

<!-- File: invoice.html -->
<!-- CSS konservatif: renderer getAs PDF cuma paham layout lawas, no flex/grid. -->
<style>
  body { font-family: Arial, sans-serif; color: #222; }
  table { width: 100%; border-collapse: collapse; }
  .items { margin-top: 16px; }
  .items td, .items th { border: 1px solid #ccc; padding: 8px; text-align: left; }
  /* Header dua kolom pakai tabel, bukan flexbox */
  .head td { border: none; padding: 0; vertical-align: top; }
  .head .kanan { text-align: right; }
  .total { text-align: right; font-size: 18px; margin-top: 12px; }
</style>

<table class="head">
  <tr>
    <td>
      <img src="<?= logoData ?>" width="120"><br>
      <strong>Toko Saya</strong>
    </td>
    <td class="kanan">
      <strong>INVOICE</strong><br>
      <?= data.nomor ?><br>
      <?= data.tanggal ?>
    </td>
  </tr>
</table>

<p>Kepada: <strong><?= data.nama ?></strong></p>

<table class="items">
  <tr><th>Item</th><th>Qty</th><th>Harga</th></tr>
  <tr>
    <td><?= data.item ?></td>
    <td><?= data.qty ?></td>
    <td><?= rupiah(data.harga) ?></td>
  </tr>
</table>

<p class="total">Total: <strong><?= rupiah(data.total) ?></strong></p>
<p><em>Terbilang: <?= terbilang(data.total) ?> rupiah.</em></p>

Lalu fungsi yang merakit dan menyimpannya ke Drive. Logonya kita tanam sebagai base64, bukan URL, biar pasti ikut ke-render. Ganti FOLDER_ID dengan id folder Drive tempat kamu mau simpan nota, dan LOGO_FILE_ID dengan id file logo (dua-duanya ambil dari URL-nya di Drive).

function createPdf(data) {
  const t = HtmlService.createTemplateFromFile('invoice');
  t.data = data;
  t.rupiah = rupiah;
  t.terbilang = terbilang;
  t.logoData = logoBase64(); // data URI, bukan URL eksternal

  const html = t.evaluate().getContent();

  const blob = Utilities
    .newBlob(html, 'text/html', 'invoice.html')
    .getAs('application/pdf')
    .setName(data.nomor.replace(/\//g, '-') + '.pdf');

  const folder = DriveApp.getFolderById('FOLDER_ID');
  const file = folder.createFile(blob);
  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

  return file;
}

// Tanam logo sebagai base64 (data URI) biar ikut ke-render tanpa nembak server luar.
// Simpan file logo di Drive, ambil ID-nya dari URL, tempel di bawah.
function logoBase64() {
  const blob = DriveApp.getFileById('LOGO_FILE_ID').getBlob();
  return 'data:' + blob.getContentType() + ';base64,' + Utilities.base64Encode(blob.getBytes());
}

Baris setSharing itu penting kalau kamu mau kirim link PDF ke pelanggan. Tanpa itu, mereka buka link malah kena tembok minta akses. Soal logo, jangan andalkan <img src> ke URL publik: di converter getAs ini, fetch gambar eksternal sering gagal dan logonya tampil kosong. Makanya default-nya base64 lewat logoBase64() tadi. Kalau logomu masih nyangkut di URL, encode sekali di awal pakai Utilities.base64Encode(UrlFetchApp.fetch(url).getBlob().getBytes()), lalu bungkus jadi data URI seperti di fungsi itu.

Kirim otomatis ke WhatsApp atau email

PDF sudah nangkring di Drive. Tinggal dilempar ke pelanggan. Email paling gampang karena bisa lampiran langsung:

function kirimEmail(data, file) {
  if (!data.email) return;
  GmailApp.sendEmail(data.email, 'Invoice ' + data.nomor,
    'Halo ' + data.nama + ',\n\nTerlampir invoice Anda. Terima kasih.', {
      name: 'Toko Saya',
      attachments: [file.getAs('application/pdf')]
    });
}

Untuk WhatsApp, kita nggak bisa lampirkan file langsung tanpa API gateway. Trik praktisnya: kirim link PDF-nya lewat wa.me. Nomor HP harus format internasional (62..., bukan 08...).

function waLink(data, fileUrl) {
  let hp = String(data.hp).replace(/\D/g, ''); // buang semua non-digit dulu
  if (hp.charAt(0) === '0') hp = '62' + hp.slice(1);       // 0812... -> 62812...
  else if (hp.slice(0, 2) !== '62') hp = '62' + hp;        // 812... (tanpa 0) -> 62812...
  // kalau sudah diawali 62, biarkan

  const pesan = 'Halo ' + data.nama + ', invoice ' + data.nomor +
    ' totalnya ' + rupiah(data.total) + '. Unduh: ' + fileUrl;
  return 'https://wa.me/' + hp + '?text=' + encodeURIComponent(pesan);
}

Ingat catatan tadi soal kolom hp. Google Sheets kerap nyimpen nomor HP sebagai angka, dan nol depannya langsung ilang jadi 81234567. Kalau itu kejadian, prefix '62' tak akan pernah nambah dengan benar. Dua pengaman: set format kolom hp ke Teks di sheet, dan waLink() di atas sudah nangani kasus nomor tanpa 0 depan biar tetap dapat awalan 62.

Kalau mau benar-benar auto-send tanpa buka wa.me manual, kamu butuh gateway WhatsApp yang dipanggil pakai UrlFetchApp. Cara nyambungin Apps Script ke WhatsApp saya bahas terpisah di panduan kirim WhatsApp dari Google Spreadsheet. Untuk email otomatis dengan trigger, mampir ke panduan kirim email otomatis.

Jadikan web app: satu tombol Buat Invoice

Kode di atas jalan dari editor, tapi istri, admin, atau kasir kamu nggak bakal mau buka editor script tiap transaksi. Bungkus jadi web app dengan satu tombol. Fungsi orkestratornya:

function generateInvoice(rowIndex) {
  const data = buildInvoice(rowIndex);

  if (!data.nomor) {
    data.nomor = nomorBerikutnya();
    SpreadsheetApp.getActiveSpreadsheet()
      .getSheetByName('Data')
      .getRange(rowIndex, 1) // asumsi kolom 1 = nomor
      .setValue(data.nomor);
  }

  const file = createPdf(data);
  if (data.email) kirimEmail(data, file);

  return {
    url: file.getUrl(),
    wa: waLink(data, file.getUrl())
  };
}

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('Buat Invoice');
}

Lalu file index.html berisi tombol yang manggil fungsi tadi lewat google.script.run, tanpa reload halaman:

<!-- File: index.html -->
<input id="baris" type="number" placeholder="Nomor baris" value="2">
<button onclick="buat()">Buat Invoice</button>
<p id="hasil"></p>

<script>
  function buat() {
    var baris = Number(document.getElementById('baris').value);
    document.getElementById('hasil').textContent = 'Memproses...';
    google.script.run
      .withSuccessHandler(function (res) {
        document.getElementById('hasil').innerHTML =
          '<a href="' + res.url + '" target="_blank">Buka PDF</a> | ' +
          '<a href="' + res.wa + '" target="_blank">Kirim WA</a>';
      })
      .withFailureHandler(function (e) {
        document.getElementById('hasil').textContent = 'Gagal: ' + e.message;
      })
      .generateInvoice(baris);
  }
</script>

Deploy sebagai web app (Deploy, New deployment, tipe Web app, execute as Me, akses sesuai kebutuhan), buka URL-nya, isi nomor baris, klik. PDF jadi, link WA siap. Langkah deploy dan izin lengkapnya ada di panduan bikin web app dari Google Spreadsheet. Kalau input transaksinya juga mau dari HP, sambungkan dengan form input data ke sheet dari HP.

Detail yang bikin beda

  • Kuota. GmailApp punya batas harian dan getAs PDF makan waktu eksekusi. Kalau volume ratusan per hari, cek dulu batas kuota Apps Script biar nggak mendadak berhenti di tengah jam sibuk.
  • Multi-item. Model satu baris tadi gampang naik kelas: bikin sheet Items, filter by nomor, loop di template pakai scriptlet forEach.
  • Arsip. File numpuk di Drive itu justru bagus, itu arsip nota otomatis kamu. Tapi kasih subfolder per bulan biar nggak jadi satu folder isi ribuan file.
  • Logo. Tanam sebagai base64 langsung di template lewat logoBase64. Gambar dari Drive atau URL publik sering nggak ke-fetch pas render PDF, ini jebakan klasik yang bikin logo ilang.
  • Format kolom. hp dan nomor invoice set ke Teks di sheet, biar nol depan dan garis miring nggak diutak-atik Sheets diam-diam.

Butuh titik awal yang matang, bukan mulai dari file kosong? Ada beberapa template siap pakai yang bisa kamu adaptasi jadi generator nota kamu sendiri, atau lihat dulu contoh hasilnya.

Kalau nggak mau nulis semua ini sendiri

Kode di atas jalan. Tapi menyusun semuanya rapi, mikirin edge case penomoran, kuota, folder arsip, format kolom hp, sampai layout PDF yang aman di renderer lawas, itu tetap kerjaan beberapa jam. Cara cepatnya: jelaskan dulu maunya apa dalam bentuk PRD yang terstruktur, biar AI coding agent kamu yang nyusun kodenya tanpa nebak-nebak.

Skematik bikin PRD mode Web GAS itu: kamu deskripsikan alur invoice, kolom sheet, dan cara kirimnya, lalu keluar spesifikasi plus scaffold Apps Script yang tinggal kamu tempel dan deploy. Jauh lebih rapi daripada nge-prompt agent dari nol.

Rancang app invoice mode Web GAS

Sheet kamu sudah nyimpen semua datanya. Sekarang suruh dia yang bikin notanya.

Buat Invoice PDF Otomatis dari Spreadsheet — Skematik