Skip to main content
BlogBackend

Kenapa Buku Kas ERP Saya Adalah View UNION Postgres Turunan

Bagaimana saya membunuh tabel ledger yang rawan drift dan membangun ulang buku kas JID Carwash sebagai view UNION Postgres turunan atas payment, expense, dan kasbon.

Matthews Wong

Software Engineer

Juli 20269 menit baca
Kenapa Buku Kas ERP Saya Adalah View UNION Postgres Turunan

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.

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

AspekTabel ledger tertulisView UNION turunan
Bisa drift dari sumberYa, pada sinkronisasi gagal atau rollback terlambatTidak, ia adalah sumbernya
Kompleksitas jalur tulisSetiap event harus juga menulis baris ledgerEvent cukup menulis dirinya sendiri
Biaya bacaSekali scan yang murahSedikit lebih tinggi, dibatasi index per cabang
Sumber kas baruBackfill plus kode sinkronisasi baruTambah 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.

FAQ

Pertanyaan Umum

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

Kenapa membuat buku kas sebagai view alih-alih tabel?

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.

Apakah view UNION ALL menjadi lambat saat data bertambah?

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.

Bagaimana cara mengindeks view Postgres?

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.

Apakah ini sama dengan event sourcing?

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.

Kapan sebaiknya memakai materialized view?

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.