Ringkasan Utama
Di ERP JID Carwash, buku kas tidak pernah berupa tabel tersimpan. Ia adalah read model Postgres: sebuah view UNION ALL yang menggabungkan payment yang sudah settle, expense, dan pencairan kasbon menjadi satu ledger terurut waktu, dihitung pada setiap pembacaan. Saldo yang ditulis akan drift begitu satu insert gagal atau rollback datang terlambat. View turunan tidak bisa berbohong soal uang, karena ia menghitung ulang kebenarannya di setiap query.
Pemilik carwash menanyakan satu hal setiap tutup toko tiap malam: berapa uang tunai yang seharusnya ada di laci sekarang? Selama tiga minggu pertama ERP JID Carwash, saya menjawabnya dengan cara yang paling jelas. Saya punya tabel CashLedgerEntry. Setiap payment, setiap expense, setiap pencairan uang muka karyawan menulis satu baris ke sana, dan saldo berjalan adalah kolom yang saya naik-turunkan terus. Rasanya rapi. Ternyata itu jebakan.
Bug yang mengakhiri tabel tertulis itu sepele. Sebuah payment settle, baris ledger tercommit, lalu transaksi di sekelilingnya yang membatalkan payment rollback sedikit terlambat. Payment-nya hilang; baris ledger yang berkata plus 45.000 IDR tidak. Laci kini mengklaim uang yang tidak pernah ada. Dua sumber kebenaran saling bertentangan, dan yang dipercaya pemilik justru yang salah.
Satu sumber kebenaran, atau tidak sama sekali
Kesalahan intinya adalah menyimpan fakta yang sama di dua tempat: sekali sebagai event yang menyebabkan pergerakan kas (payment, expense, kasbon), dan sekali lagi sebagai baris ledger terdenormalisasi yang mendeskripsikannya. Setiap kali sebuah fakta hidup dua kali, Anda berkomitmen menjaga kedua salinan tetap sinkron selamanya, di setiap jalur kode, setiap kegagalan, setiap fitur mendatang. Di POS yang menangani uang tunai di bawah beban, sinkronisasi itu pada akhirnya akan rusak. Ketika rusak di seputar uang, Anda tidak dapat peringatan, Anda dapat pemilik yang marah.
-- The original design: a written ledger the app kept in sync by hand.
INSERT INTO cash_ledger_entry (branch_id, entry_type, delta_idr, occurred_at)
VALUES (3, 'PAYMENT', 45000, now()); -- committed here
-- ...meanwhile the payment that justified it got voided,
-- and its transaction rolled back a beat too late.
-- The ledger row survives. The payment does not.
-- Drawer now reports 45,000 IDR that no event supports.Maka saya menghapus tabelnya. Buku kas menjadi sebuah view: query bernama yang tidak menyimpan data sendiri, dihitung ulang dari event sumber setiap kali seseorang membacanya. Ini adalah ide read model dari event sourcing dan CQRS, dikecilkan menjadi satu view Postgres. Event (payment, expense, kasbon) adalah write model dan satu-satunya kebenaran yang tahan lama. Buku kas adalah proyeksi atas mereka. Jika sebuah payment tidak pernah benar-benar terjadi, ia tidak pernah muncul di proyeksi, karena tidak ada yang bisa diproyeksikan.
Seperti apa sebenarnya view-nya
Buku kas melakukan UNION atas setiap tabel yang menggerakkan kas, menormalkan masing-masing menjadi bentuk enam kolom yang sama: sebuah source id, tipe entri, cabang pemiliknya, kapan terjadi, delta bilangan bulat bertanda dalam rupiah, dan memo untuk manusia. Uang selalu bilangan bulat IDR, tidak pernah float, jadi delta positif berarti kas masuk dan delta negatif berarti kas keluar. Payment menambah, expense dan kasbon mengurangi. Saldo berjalan hanyalah SUM berjendela atas occurred_at, dihitung saat pembacaan.
-- cash_ledger_entry is a VIEW, not a table.
-- Every row is derived; nothing here is ever INSERTed.
CREATE VIEW cash_ledger_entry AS
SELECT p.id AS source_id,
'PAYMENT' AS entry_type,
p.branch_id AS branch_id,
p.settled_at AS occurred_at,
p.amount_idr AS delta_idr, -- integer IDR, cash in
p.reference AS memo
FROM payment p
WHERE p.status = 'SETTLED'
UNION ALL
SELECT e.id, 'EXPENSE', e.branch_id, e.paid_at,
-e.amount_idr, -- cash leaves the drawer
e.category
FROM expense e
UNION ALL
SELECT k.id, 'KASBON', k.branch_id, k.disbursed_at,
-k.amount_idr, -- employee cash advance out
k.employee_name
FROM kasbon k
WHERE k.disbursed_at IS NOT NULL;UNION ALL, bukan UNION. UNION biasa memaksa Postgres mengurutkan setiap baris dan membuang duplikat, yang murni pekerjaan sia-sia di sini karena id payment dan id expense tidak mungkin bentrok antar tipe entri. Di cabang yang sibuk, sort itu adalah perbedaan antara layar tutup toko yang gesit dan spinner. Gunakan UNION hanya bila duplikat memang mungkin dan tidak diinginkan.
Membuat view turunan cepat di Postgres
Kekhawatiran yang jelas adalah performa: view dihitung ulang pada setiap pembacaan, jadi apakah ia menjadi lambat? Dalam praktiknya view itu sendiri tidak menyimpan data dan tidak butuh index. Yang penting adalah setiap tabel sumber di bawahnya terindeks untuk filter persis yang selalu dikirim API. Setiap permintaan buku kas dibatasi oleh branch_id dan rentang tanggal, scoping cabang aman IDOR yang sama seperti di setiap query list di codebase, jadi saya mengindeks tiap tabel dasar pada (branch_id, waktu-kejadian) dan membiarkan planner mendorong predikat itu ke bawah.
Postgres mengubah UNION ALL menjadi node Append, lalu menarik klausa WHERE luar ke dalam setiap cabang append (rewrite yang pganalyze sebut subquery pull-up). Tiap cabang menjadi index range scan sendiri yang dibatasi cabang dan tanggal, bukan full scan atas tiga tabel. Satu aturan yang menjaga ini tetap berfungsi: kolom di kedua sisi UNION ALL harus sejajar tipe secara persis. Satu implicit cast liar memblokir pull-up dan Anda jatuh kembali ke memindai segalanya.
-- The view needs no index. The base tables carry them.
CREATE INDEX idx_payment_branch_settled
ON payment (branch_id, settled_at)
WHERE status = 'SETTLED'; -- partial: mirrors the view's filter
CREATE INDEX idx_expense_branch_paid
ON expense (branch_id, paid_at);
CREATE INDEX idx_kasbon_branch_disbursed
ON kasbon (branch_id, disbursed_at)
WHERE disbursed_at IS NOT NULL;
-- The API always sends branch + date range, so the planner
-- pushes the predicate INTO each Append branch:
EXPLAIN ANALYZE
SELECT * FROM cash_ledger_entry
WHERE branch_id = 3
AND occurred_at >= DATE '2026-07-01'
ORDER BY occurred_at DESC
LIMIT 50;
-- Append over three index scans; ~2 ms on a full day of one branch.Cocokkan predikat partial index Anda dengan predikat view. View memfilter payment ke status SETTLED, jadi index payment bersifat partial pada kondisi yang sama. Index lalu hanya menyimpan baris yang mungkin dikembalikan view, tetap lebih kecil, dan planner memakainya tanpa ragu. Penyelarasan satu ini yang membuat view tetap view biasa alih-alih memaksa yang materialized.
View turunan versus tabel tertulis
| Aspek | Tabel ledger tertulis | View UNION turunan |
|---|---|---|
| Bisa drift dari sumber | Ya, pada sinkronisasi gagal atau rollback terlambat | Tidak, ia adalah sumbernya |
| Kompleksitas jalur tulis | Setiap event harus juga menulis baris ledger | Event cukup menulis dirinya sendiri |
| Biaya baca | Sekali scan yang murah | Sedikit lebih tinggi, dibatasi index per cabang |
| Sumber kas baru | Backfill plus kode sinkronisasi baru | Tambah satu cabang UNION ALL |
Kapan saya akan memilih materialized view
View biasa menghitung ulang pada setiap pembacaan, dan itulah yang persis saya mau untuk buku kas yang tidak boleh berbohong. Tetapi jika ledger menyuapi dashboard analitik berat sepanjang bulan yang mengagregasi semua cabang sekaligus, penghitungan ulang berhenti gratis. Itulah saat untuk materialized view: query yang sama, tetapi hasilnya disimpan dan disegarkan terjadwal, menukar sedikit keusangan demi scan cepat. Aturan yang saya ikuti sederhana.
- Kritis terhadap kebenaran dan dibaca langsung, seperti saldo laci saat tutup toko: view biasa, selalu segar.
- Agregat mahal yang menoleransi keterlambatan beberapa menit, seperti laporan bulanan lintas cabang: materialized view, disegarkan terjadwal.
- Jangan pernah tabel terdenormalisasi yang disinkronkan manual untuk uang. Itu jebakan drift yang sudah saya bayar sekali.
Ini prinsip yang sama di balik Product.stockQty di ERP: kolomnya adalah cache, dan SUM atas pergerakan stok adalah jawaban sebenarnya. Buku kas membawa ide itu ke kesimpulannya dan membuang cache-nya sama sekali. Di mana uang dan inventaris terlibat, lebih baik turunan yang selalu bisa dihitung ulang daripada angka tersimpan yang harus Anda bela.

