Ringkasan Utama
Alur quote-to-cash pada ERP sering kacau ketika penawaran, sales order, pengiriman, invoice, dan pembayaran hanya mengandalkan satu kolom status. Solusinya: jadikan tiap dokumen sebagai finite state machine terpisah yang hanya bisa diubah lewat satu jalur kode, turunkan invoice langsung dari data pengiriman, dan bangun alokasi pembayaran sebagai relasi banyak-ke-banyak antara pembayaran dan invoice sejak awal.
Setiap sistem ERP pada akhirnya harus menjawab pertanyaan yang sama: bagaimana sebuah penawaran berubah menjadi uang tunai di rekening tanpa ada pesanan yang terkirim dua kali atau invoice yang terbit dua kali untuk pelanggan yang sama. Jawabannya bukan satu fitur tunggal. Jawabannya adalah rangkaian dokumen berbeda, masing-masing dengan siklus hidupnya sendiri, yang dirangkai oleh state machine yang menolak membiarkan sebuah data melompati langkah yang belum ia lalui.
Tulisan ini membahas alur quote-to-cash sebagaimana biasa saya bangun untuk klien ERP: penawaran, sales order, pengiriman, invoice, dan pembayaran, beserta pengaman yang mencegah alur ini diam-diam kehilangan konsistensi. Asumsinya adalah basis data relasional, layer service di backend, dan kesediaan untuk memodelkan aturan bisnis sebagai kode, bukan sekadar pengetahuan tak tertulis di kepala tim.
Mengapa quote-to-cash butuh state machine, bukan sekadar kolom status
Implementasi naif memberikan setiap dokumen sebuah kolom teks status dan membiarkan bagian mana pun dari kode menulis ke kolom itu. Cara ini berjalan untuk demo tetapi gagal di produksi, karena tidak ada yang mencegah bug di modul gudang menandai sebuah pesanan sebagai lunas, atau race condition menerbitkan invoice dua kali untuk pengiriman yang sama. Solusinya adalah memperlakukan setiap jenis dokumen sebagai finite state machine: sekumpulan status yang tetap, sekumpulan transisi yang diizinkan, dan satu jalur kode saja yang berhak memindahkan sebuah data dari satu status ke status berikutnya.
Quote-to-Cash State Machine
DRAFT ──► SENT ──► ACCEPTED ──► CONVERTED (Sales Order)
│ │
└──► REJECTED ▼
CONFIRMED
│
▼
ALLOCATED ──► PICKED ──► SHIPPED
│
▼
INVOICED
│
┌───────────────────────┤
▼ ▼
PARTIALLY_PAID OVERDUE
│ │
└──────────┬────────────┘
▼
PAID (terminal)Perhatikan bahwa sales order tidak langsung berstatus diinvoice begitu dikonfirmasi. Ia harus melewati alokasi stok, pengambilan barang, dan pengiriman terlebih dahulu, masing-masing adalah titik pemeriksaan bahwa suatu kejadian nyata memang telah terjadi. Sebuah pembayaran kemudian hanya boleh mendorong invoice yang sudah terbit menuju status sebagian dibayar atau lunas, tidak pernah mundur. Setelah grafik transisi ini digambarkan, sebagian besar kasus khusus yang biasanya ditambal dengan logika kondisi tambahan justru menjadi transisi yang memang tidak pernah didefinisikan, sehingga kode menolaknya secara otomatis.
Lima dokumen dan tanggung jawab masing-masing
Quote-to-cash sebenarnya adalah lima agregat data terpisah yang saling terhubung lewat foreign key, bukan satu tabel raksasa. Memisahkannya dengan rapi adalah yang memungkinkan Anda mengubah harga penawaran tanpa menyentuh pesanan yang sudah terkirim, atau menerbitkan invoice parsial tanpa menghambat sisa sales order. Pemisahan ini juga berarti setiap dokumen bisa memiliki aturan persetujuannya sendiri, urutan penomoran sendiri, dan kebijakan retensi sendiri, yang menjadi penting begitu tim finance dan audit mulai meminta riwayat per dokumen, bukan sekadar satu data pesanan yang telah diratakan.
| Dokumen | Tanggung jawab | Status akhir umum |
|---|---|---|
| Penawaran | Baris item, harga, dan masa berlaku yang diajukan sebelum ada komitmen resmi | Diterima atau ditolak |
| Sales order | Komitmen yang telah dikonfirmasi: jumlah, harga yang disetujui, alamat pengiriman, dan hasil pemeriksaan kredit | Terkirim penuh dan terinvoice |
| Pengiriman | Alokasi gudang, pengambilan barang, pengepakan, dan referensi pengiriman | Terkirim dan terkonfirmasi diterima |
| Invoice | Klaim resmi atas pembayaran, terkait pada satu atau lebih pengiriman | Lunas atau dihapusbukukan |
| Pembayaran | Pergerakan uang tunai yang sebenarnya dan alokasinya pada satu atau lebih invoice | Teralokasi penuh |
Modelkan penawaran dan sales order sebagai dua tabel terpisah meski keduanya berbagi hampir seluruh kolom. Penawaran adalah proposal dan bisa diedit bebas. Sales order adalah komitmen dan setiap perubahannya harus menjadi kejadian yang tercatat. Menggabungkan keduanya menjadi satu tabel dengan penanda status membuat perbedaan ini mustahil ditegakkan di kemudian hari.
Mengubah penawaran menjadi sales order
Langkah konversi ini adalah momen di mana kebanyakan ERP kustom benar-benar dibutuhkan, karena inilah saat janji seorang sales berubah menjadi sesuatu yang wajib dipenuhi oleh tim gudang dan finance. Handler konversi yang rapi melakukan empat hal dalam satu transaksi, dan tidak satu pun boleh dilewatkan jika Anda ingin pembukuan tetap benar.
- Validasi ulang harga dan ketersediaan stok, karena sebuah penawaran bisa sudah berumur beberapa hari atau minggu saat pelanggan akhirnya menyetujuinya
- Jalankan pemeriksaan kredit terhadap saldo tertunggak dan limit kredit pelanggan sebelum pesanan diizinkan mencapai status terkonfirmasi
- Salin baris item dari penawaran yang diterima ke baris sales order yang baru, bukan mereferensikan langsung baris penawaran, sehingga perubahan di salah satu dokumen tidak pernah merembes ke dokumen lain
- Catat transisi ini beserta pengguna yang melakukannya, waktu kejadian, dan id penawaran asal, sehingga perselisihan di kemudian hari punya jejak audit lengkap kembali ke apa yang sebenarnya disetujui pelanggan
-- PostgreSQL: sales_order status column with a guarded transition table
CREATE TYPE sales_order_status AS ENUM (
'draft', 'confirmed', 'allocated', 'picked',
'shipped', 'invoiced', 'partially_paid', 'overdue', 'paid'
);
CREATE TABLE sales_order (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quotation_id UUID REFERENCES quotation(id),
customer_id UUID NOT NULL REFERENCES customer(id),
status sales_order_status NOT NULL DEFAULT 'draft',
total_amount NUMERIC(14, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Allowed transitions live in application code, not a trigger,
-- so the workflow engine can log who approved each hop.
CREATE TABLE sales_order_transition (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sales_order_id UUID NOT NULL REFERENCES sales_order(id),
from_status sales_order_status NOT NULL,
to_status sales_order_status NOT NULL,
actor_id UUID NOT NULL REFERENCES employee(id),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Pengiriman dan momen invoicing menjadi sah
Sales order sebaiknya tidak pernah langsung diinvoice. Ia diinvoice karena sebuah pengiriman terjadi, atau karena sebuah milestone kontrak terpenuhi. Perbedaan ini penting untuk kepatuhan pajak di kebanyakan wilayah hukum, dan penting untuk integritas data Anda sendiri: jika invoicing membaca langsung dari sales order, pengiriman parsial tidak punya cara untuk memicu invoice parsial, dan seluruh alur akan merosot menjadi menginvoice semuanya di awal lalu berharap fulfillment menyusul. Di Indonesia tarifnya sendiri memang dirancang bisa berubah. UU No. 42/2009 Pasal 7 menetapkan tarif PPN sekaligus memberi pemerintah wewenang mengubahnya antara 5% dan 15% lewat peraturan, dan menempatkan PPnBM di rentang 10% sampai 200%. Tarif utamanya sudah berubah sejak itu, dan di situlah alasan untuk tidak menuliskannya sebagai konstanta: simpan tarif sebagai konfigurasi dengan tanggal berlaku, agar invoice tahun lalu tetap dihitung ulang sebagaimana diterbitkan.
Jangan biarkan modul invoice menghitung baris itemnya sendiri dari sales order. Selalu turunkan baris invoice dari data pengiriman yang memicu invoice tersebut. Jika tidak, pesanan yang terkirim sebagian dan pesanan yang terinvoice penuh bisa ada secara bersamaan, dan merekonsiliasi ketidaksesuaian itu berbulan-bulan kemudian saat audit jauh lebih mahal dibanding menambahkan foreign key tambahan sekarang.
Invoicing dan pencocokan pembayaran
Setelah sebuah pengiriman ditutup, proses pembuatan invoice membuat invoice yang mereferensikan baris item pengiriman tersebut, nomor invoice sekuensial miliknya sendiri, tanggal jatuh tempo yang dihitung dari syarat pembayaran pelanggan, dan status yang dimulai dari terkirim. Pencocokan pembayaran adalah bagian yang sering diremehkan tim, karena di dunia nyata pembayaran jarang datang sebagai satu transfer bersih per invoice.
- Seorang pelanggan membayar tiga invoice dengan satu transfer bank, sehingga data pembayaran memerlukan tabel alokasi banyak-ke-banyak antara pembayaran dan invoice, bukan satu kolom id invoice tunggal
- Seorang pelanggan membayar kurang karena selisih pembulatan atau biaya bank, sehingga logika pencocokan memerlukan ambang toleransi sebelum menandai invoice sebagai ketidaksesuaian yang perlu ditinjau manual
- Seorang pelanggan membayar lebih, sehingga kelebihannya sebaiknya menjadi saldo kredit pada data pelanggan, bukan dibuang begitu saja atau dipaksakan ke invoice berikutnya tanpa jejak pencatatan
Bangun tabel alokasi pembayaran sejak hari pertama sebagai relasi banyak-ke-banyak antara pembayaran dan invoice, meskipun pelanggan pertama Anda selalu hanya membayar satu invoice pada satu waktu. Mengubah relasi satu-ke-satu menjadi banyak-ke-banyak setelah tim finance memiliki data pembayaran satu-ke-satu selama bertahun-tahun adalah salah satu migrasi paling menyakitkan dalam basis kode ERP.
Menjaga alur tetap dapat diamati
State machine memberi Anda kebenaran data, tetapi Anda juga membutuhkan visibilitas ke mana saja dokumen tersendat. Query dashboard yang menghitung sales order yang tertahan di status terkonfirmasi lebih dari sejumlah hari tertentu, atau invoice yang tertahan di status terkirim melewati tanggal jatuh temponya, mengubah alur ini dari kotak hitam menjadi sesuatu yang benar-benar bisa dikelola tim sales dan finance sehari-hari. Catat setiap transisi beserta pelaku dan waktunya sejak awal, karena tabel transisi itu akan menjadi satu-satunya sumber kebenaran untuk setiap perselisihan di masa depan tentang siapa menyetujui apa dan kapan. Seiring ERP tumbuh, log transisi yang sama juga memungkinkan Anda menambahkan peringatan otomatis, misalnya menandai sales order yang sudah teralokasi selama seminggu tanpa terkirim, tanpa perlu menulis ulang logika alur inti sama sekali.

