# 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.

URL: https://www.matthewswong.com/id/blog/derived-cash-book-union-view-postgres/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-07-14
- Updated: 2026-07-14
- Category: Backend

> **Key takeaway:** 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.

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.

> 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.

> 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.

## Sources

- PostgreSQL Documentation: Materialized Views
- pganalyze: Speed up Postgres queries with UNIONs and subquery pull-up
- Microsoft Azure Architecture Center: Event Sourcing pattern

## Source links

- https://www.postgresql.org/docs/current/rules-materializedviews.html
- https://pganalyze.com/blog/5mins-postgres-UNION-subquery-pull-up-performance
- https://learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing

## FAQ

### 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.

---

- HTML version: https://www.matthewswong.com/id/blog/derived-cash-book-union-view-postgres/
- English: https://www.matthewswong.com/en/blog/derived-cash-book-union-view-postgres/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
