Kenapa Buku Kas ERP Saya Adalah View UNION Postgres Turunan

Foto oleh Jemimus via Openverse (CC BY 2.0)
Tabel ledger tertulis menyimpan fakta yang sama dua kali: sekali sebagai event yang menggerakkan kas dan sekali sebagai baris ledger yang mendeskripsikannya. Kedua salinan itu drift setiap kali sinkronisasi gagal atau transaksi rollback terlambat. Sebuah view menurunkan ledger dari event pada setiap pembacaan, jadi ia tidak akan pernah bertentangan dengan sumber kebenarannya sendiri.
Tidak, jika tabel dasar terindeks untuk query yang benar-benar Anda jalankan. View sendiri tidak menyimpan data. Karena setiap permintaan dibatasi cabang dan rentang tanggal, mengindeks tiap tabel sumber pada (branch_id, timestamp) membuat Postgres mendorong filter itu ke tiap cabang Append dan melakukan index scan terbatas alih-alih full table scan.
Anda tidak mengindeks view biasa secara langsung, karena ia tidak menyimpan apa pun. Anda mengindeks tabel dasar di bawahnya agar cocok dengan filter view dan predikat query. Partial index yang mencerminkan klausa WHERE view, seperti status sama dengan SETTLED, menjaga index tetap kecil dan menjamin planner memakainya.
Ia meminjam ide intinya. Tabel payment, expense, dan kasbon bertindak sebagai write model dan kebenaran tahan lama, dan view buku kas adalah proyeksi read model atas mereka, dalam semangat CQRS. Ini bukan event sourcing penuh karena sumbernya adalah tabel current-state, bukan log event append-only, tetapi disiplin read model turunannya identik.
Gunakan materialized view ketika query mahal dan bisa menoleransi sedikit keusangan, seperti laporan bulanan yang mengagregasi semua cabang. Pertahankan view biasa ketika kebenaran harus langsung, seperti saldo laci saat tutup toko. Jangan pernah kembali ke tabel terdenormalisasi yang disinkronkan manual untuk uang, karena itulah masalah drift yang ingin Anda hindari.

Foto oleh Jemimus via Openverse (CC BY 2.0)
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.
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.
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.
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.
| 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 |
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.
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.