Pencatatan yang rapi baru setengah dari pekerjaan; setengahnya lagi adalah membuat data itu bisa diinterogasi: “sebenarnya aku menghabiskan berapa untuk makan bulan ini?” Pertanyaan itu menuntut kategori yang bertingkat — karena makan di luar dan makan di rumah adalah dua kebiasaan yang berbeda — dan menuntut anggaran yang bisa menghitung mundur sisa batas. Pada bagian ini kita melengkapi pohon kategori menjadi dua tingkat, menggulung pengeluaran dari subkategori ke kategori utama dengan self-join dan ROLLUP, lalu memasang anggaran bulanan yang mendeteksi pelanggarannya sendiri.
Menyiapkan Tabel #
Sesuai pola proyek ini, bagian berdiri sendiri: skema dibangun ulang dari nol sehingga Anda bisa masuk langsung dari bagian mana pun. Berikut siapkan-tabel.sql:
-- siapkan-tabel.sql — skema keuangan pribadi, versi ringkas untuk bagian ini.
-- Definisi identik dengan Bagian 1; setiap bagian membangun ulang dari nol.
CREATE TYPE tipe_arus_kas AS ENUM ('pemasukan', 'pengeluaran');
CREATE TABLE akun (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL UNIQUE,
jenis text NOT NULL CHECK (jenis IN ('kas', 'rekening', 'dompet digital')),
saldo_awal numeric(14,2) NOT NULL CHECK (saldo_awal >= 0)
);
CREATE TABLE kategori (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
jenis tipe_arus_kas NOT NULL,
parent_id integer,
CONSTRAINT induk_sama_jenis
FOREIGN KEY (parent_id, jenis) REFERENCES kategori (id, jenis),
CONSTRAINT bukan_induk_diri_sendiri
CHECK (parent_id IS NULL OR parent_id <> id),
CONSTRAINT pasangan_id_jenis_unik UNIQUE (id, jenis)
);
CREATE TABLE transaksi (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
akun_id integer NOT NULL REFERENCES akun (id),
kategori_id integer NOT NULL,
tipe tipe_arus_kas NOT NULL,
tanggal date NOT NULL,
deskripsi text NOT NULL,
jumlah numeric(14,2) NOT NULL,
CONSTRAINT jumlah_harus_positif CHECK (jumlah > 0),
CONSTRAINT tipe_cocok_dengan_kategori
FOREIGN KEY (kategori_id, tipe) REFERENCES kategori (id, jenis)
);
CREATE TABLE anggaran (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kategori_id integer NOT NULL REFERENCES kategori (id),
periode date NOT NULL,
batas_jumlah numeric(14,2) NOT NULL,
CONSTRAINT batas_harus_positif CHECK (batas_jumlah > 0),
CONSTRAINT periode_awal_bulan CHECK (periode = date_trunc('month', periode)::date),
CONSTRAINT satu_anggaran_per_periode UNIQUE (kategori_id, periode)
);
CREATE INDEX idx_transaksi_akun_tanggal ON transaksi (akun_id, tanggal);
CREATE INDEX idx_transaksi_kategori ON transaksi (kategori_id);
CREATE INDEX idx_transaksi_tanggal ON transaksi (tanggal);Output yang Dihasilkan:
CREATE TYPE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEXPohon Kategori yang Lengkap #
Enam belas kategori Budi tersusun dua tingkat: lima kategori utama pengeluaran (makan, transportasi, tagihan, hiburan) dan kesehatan yang berdiri sendiri, masing-masing dengan anak-anaknya, ditambah dua kategori pemasukan. Berikut isi-kategori.sql:
-- isi-kategori.sql — pohon kategori lengkap: pemasukan dan pengeluaran.
-- Dua tingkat: induk (makan, transportasi, tagihan, hiburan) dan anak
-- (makan di luar, bensin, listrik, streaming, dan seterusnya).
INSERT INTO kategori (nama, jenis, parent_id) VALUES
('gaji', 'pemasukan', NULL), -- 1
('pemasukan lain', 'pemasukan', NULL), -- 2
('makan', 'pengeluaran', NULL), -- 3
('makan di luar', 'pengeluaran', 3), -- 4
('makan di rumah', 'pengeluaran', 3), -- 5
('transportasi', 'pengeluaran', NULL), -- 6
('bensin', 'pengeluaran', 6), -- 7
('parkir dan tol', 'pengeluaran', 6), -- 8
('tagihan', 'pengeluaran', NULL), -- 9
('sewa tempat tinggal', 'pengeluaran', 9), -- 10
('listrik', 'pengeluaran', 9), -- 11
('internet', 'pengeluaran', 9), -- 12
('hiburan', 'pengeluaran', NULL), -- 13
('streaming', 'pengeluaran', 13), -- 14
('rekreasi', 'pengeluaran', 13), -- 15
('kesehatan', 'pengeluaran', NULL); -- 16
-- Pohon kategori dilihat lewat self-join: tiap baris berpasangan dengan induknya.
SELECT k.id, k.nama, k.jenis, COALESCE(p.nama, '-') AS induk
FROM kategori k
LEFT JOIN kategori p ON p.id = k.parent_id
ORDER BY k.id;Output yang Dihasilkan:
INSERT 0 16
id | nama | jenis | induk
----+---------------------+-------------+--------------
1 | gaji | pemasukan | -
2 | pemasukan lain | pemasukan | -
3 | makan | pengeluaran | -
4 | makan di luar | pengeluaran | makan
5 | makan di rumah | pengeluaran | makan
6 | transportasi | pengeluaran | -
7 | bensin | pengeluaran | transportasi
8 | parkir dan tol | pengeluaran | transportasi
9 | tagihan | pengeluaran | -
10 | sewa tempat tinggal | pengeluaran | tagihan
11 | listrik | pengeluaran | tagihan
12 | internet | pengeluaran | tagihan
13 | hiburan | pengeluaran | -
14 | streaming | pengeluaran | hiburan
15 | rekreasi | pengeluaran | hiburan
16 | kesehatan | pengeluaran | -
(16 rows)Query penutupnya adalah self-join paling sederhana yang bisa dibayangkan: tabel kategori di-join ke dirinya sendiri, sekali sebagai anak (k) dan sekali sebagai induknya (p), lewat p.id = k.parent_id. Kolom induk yang kosong diganti - dengan COALESCE agar kategori tingkat atas tetap rapi terbaca. Enam belas baris itu adalah seluruh taksonomi keuangan Budi — dan karena constraint induk_sama_jenis dari Bagian 1 masih berdiri, tidak ada satu pun subkategori yang berbeda jenis dengan induknya.
Menggulung Pengeluaran ke Kategori Utama #
Kekuatan hierarki baru terasa saat laporan berganti tingkat. Budi mencatat Maret 2026 — bulan Ramadhan, saat pola makan berubah — dalam dua puluh transaksi. Berkas rollup-kategori.sql mengisinya lalu merangkumnya dua cara:
-- rollup-kategori.sql — akun, transaksi Maret 2026, lalu pengeluaran
-- diringkas dari subkategori ke kategori utama dengan self-join dan ROLLUP.
INSERT INTO akun (nama, jenis, saldo_awal) VALUES
('Rekening Gaji', 'rekening', 4200000.00),
('Kas', 'kas', 750000.00),
('Dompet Digital', 'dompet digital', 600000.00);
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
(1, 10, 'pengeluaran', DATE '2026-03-01', 'Sewa tempat tinggal Maret', 2500000.00),
(2, 8, 'pengeluaran', DATE '2026-03-03', 'Parkir kantor', 8000.00),
(1, 12, 'pengeluaran', DATE '2026-03-05', 'Internet bulanan', 350000.00),
(3, 7, 'pengeluaran', DATE '2026-03-08', 'Isi bensin (QRIS)', 60000.00),
(2, 4, 'pengeluaran', DATE '2026-03-08', 'Makan siang warteg', 25000.00),
(1, 14, 'pengeluaran', DATE '2026-03-10', 'Langganan streaming', 120000.00),
(1, 5, 'pengeluaran', DATE '2026-03-11', 'Belanja dapur seminggu', 520000.00),
(3, 4, 'pengeluaran', DATE '2026-03-13', 'Kopi dengan teman', 78000.00),
(1, 11, 'pengeluaran', DATE '2026-03-15', 'Listrik Maret', 310000.00),
(3, 4, 'pengeluaran', DATE '2026-03-17', 'Brunch akhir pekan', 96000.00),
(2, 16, 'pengeluaran', DATE '2026-03-19', 'Obat dan vitamin', 185000.00),
(3, 4, 'pengeluaran', DATE '2026-03-20', 'Buka puasa di warung', 62000.00),
(1, 5, 'pengeluaran', DATE '2026-03-21', 'Belanja dapur Ramadhan', 465000.00),
(3, 7, 'pengeluaran', DATE '2026-03-22', 'Isi bensin (QRIS)', 60000.00),
(3, 4, 'pengeluaran', DATE '2026-03-24', 'Snack rapat komunitas', 45000.00),
(1, 1, 'pemasukan', DATE '2026-03-25', 'Gaji bulanan Maret', 9200000.00),
(3, 4, 'pengeluaran', DATE '2026-03-27', 'Buka puasa keluarga', 148000.00),
(2, 8, 'pengeluaran', DATE '2026-03-28', 'Parkir dan tol perjalanan', 10000.00),
(1, 15, 'pengeluaran', DATE '2026-03-29', 'Wisata keluarga akhir bulan', 450000.00),
(2, 8, 'pengeluaran', DATE '2026-03-30', 'Parkir kantor', 8000.00);
-- Rincian pengeluaran Maret per subkategori, digulung ke kategori utama.
-- COALESCE(p.nama, k.nama) menjadikan kategori tanpa induk (kesehatan)
-- sebagai kategori utama bagi dirinya sendiri.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
k.nama AS subkategori,
count(*) AS transaksi,
sum(t.jumlah) AS total
FROM transaksi t
JOIN kategori k ON k.id = t.kategori_id
LEFT JOIN kategori p ON p.id = k.parent_id
WHERE t.tipe = 'pengeluaran'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY COALESCE(p.nama, k.nama), k.nama
ORDER BY kategori_utama, total DESC;
-- ROLLUP: baris subtotal per kategori utama dan total keseluruhan
-- ditambahkan otomatis; GROUPING() menandai baris hasil gulungan.
SELECT CASE WHEN GROUPING(p.nama) = 1 THEN 'TOTAL PENGELUARAN'
ELSE p.nama END AS kategori_utama,
CASE WHEN GROUPING(k.nama) = 1 THEN '-- subtotal --'
ELSE k.nama END AS subkategori,
count(*) AS transaksi,
sum(t.jumlah) AS total
FROM transaksi t
JOIN kategori k ON k.id = t.kategori_id
JOIN kategori p ON p.id = k.parent_id
WHERE t.tipe = 'pengeluaran'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY ROLLUP (p.nama, k.nama)
ORDER BY p.nama NULLS LAST, GROUPING(k.nama), k.nama;Output yang Dihasilkan:
INSERT 0 3
INSERT 0 20
kategori_utama | subkategori | transaksi | total
----------------+---------------------+-----------+------------
hiburan | rekreasi | 1 | 450000.00
hiburan | streaming | 1 | 120000.00
kesehatan | kesehatan | 1 | 185000.00
makan | makan di rumah | 2 | 985000.00
makan | makan di luar | 6 | 454000.00
tagihan | sewa tempat tinggal | 1 | 2500000.00
tagihan | internet | 1 | 350000.00
tagihan | listrik | 1 | 310000.00
transportasi | bensin | 2 | 120000.00
transportasi | parkir dan tol | 3 | 26000.00
(10 rows)
kategori_utama | subkategori | transaksi | total
-------------------+---------------------+-----------+------------
hiburan | rekreasi | 1 | 450000.00
hiburan | streaming | 1 | 120000.00
hiburan | -- subtotal -- | 2 | 570000.00
makan | makan di luar | 6 | 454000.00
makan | makan di rumah | 2 | 985000.00
makan | -- subtotal -- | 8 | 1439000.00
tagihan | internet | 1 | 350000.00
tagihan | listrik | 1 | 310000.00
tagihan | sewa tempat tinggal | 1 | 2500000.00
tagihan | -- subtotal -- | 3 | 3160000.00
transportasi | bensin | 2 | 120000.00
transportasi | parkir dan tol | 3 | 26000.00
transportasi | -- subtotal -- | 5 | 146000.00
TOTAL PENGELUARAN | -- subtotal -- | 18 | 5315000.00
(14 rows)Dua query, dua filosofi penggulungan:
- Query pertama memakai
COALESCE(p.nama, k.nama): subkategori yang punya induk digulung ke induknya, sedangkankesehatanyang tidak punya induk menjadi kategori utama bagi dirinya sendiri. Hasilnya: rincian sepuluh baris siap dibaca — darisewa tempat tinggalRp2.500.000 sampaiparkir dan tolRp26.000. - Query kedua memakai
GROUP BY ROLLUP (p.nama, k.nama): PostgreSQL otomatis menambahkan baris subtotal per kategori utama dan satu baris total keseluruhan. FungsiGROUPING(kolom)mengembalikan 1 pada baris hasil gulungan — itulah yang mengubah label menjadi-- subtotal --danTOTAL PENGELUARAN. ORDER BY p.nama NULLS LAST, GROUPING(k.nama), k.namamenjaga urutan yang manusiawi: kategori utama alfabetis, baris subtotal selalu di bawah rinciannya, dan total keseluruhan menutup laporan. TanpaNULLS LAST, baris total (yangp.nama-nya NULL) akan melompat ke depan.
Perhatikan pula bahwa ROLLUP di sini memakai JOIN kategori p (INNER), sehingga kesehatan yang tanpa induk tidak masuk laporan berjenjang ini — ia sudah tampil utuh di query pertama. Maret ditutup dengan total pengeluaran Rp5.315.000; sekarang saatnya membandingkannya dengan anggaran.
Anggaran vs Pengeluaran Nyata #
Anggaran Budi untuk Maret ditetapkan sembilan kategori. Bulan April diisi transaksi normal kembali — pascapuasa — beserta anggaran Aprilnya. Berkas anggaran-vs-actual.sql lalu menyandingkan semuanya:
-- anggaran-vs-actual.sql — anggaran bulanan Maret dan April, transaksi April,
-- lalu perbandingan anggaran vs pengeluaran nyata dengan FILTER dan HAVING.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah) VALUES
( 4, DATE '2026-03-01', 400000.00), -- makan di luar
( 5, DATE '2026-03-01', 1000000.00), -- makan di rumah
( 7, DATE '2026-03-01', 150000.00), -- bensin
( 8, DATE '2026-03-01', 50000.00), -- parkir dan tol
(10, DATE '2026-03-01', 2500000.00), -- sewa tempat tinggal
(11, DATE '2026-03-01', 350000.00), -- listrik
(12, DATE '2026-03-01', 350000.00), -- internet
(14, DATE '2026-03-01', 150000.00), -- streaming
(15, DATE '2026-03-01', 300000.00); -- rekreasi
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
(1, 10, 'pengeluaran', DATE '2026-04-01', 'Sewa tempat tinggal April', 2500000.00),
(1, 12, 'pengeluaran', DATE '2026-04-05', 'Internet bulanan', 350000.00),
(3, 7, 'pengeluaran', DATE '2026-04-08', 'Isi bensin (QRIS)', 60000.00),
(1, 14, 'pengeluaran', DATE '2026-04-10', 'Langganan streaming', 120000.00),
(3, 4, 'pengeluaran', DATE '2026-04-13', 'Kopi sore', 45000.00),
(1, 11, 'pengeluaran', DATE '2026-04-15', 'Listrik April', 310000.00),
(3, 2, 'pemasukan', DATE '2026-04-18', 'Proyek desain lepas', 900000.00),
(1, 5, 'pengeluaran', DATE '2026-04-20', 'Belanja dapur seminggu', 480000.00),
(3, 7, 'pengeluaran', DATE '2026-04-22', 'Isi bensin (QRIS)', 60000.00),
(1, 1, 'pemasukan', DATE '2026-04-25', 'Gaji bulanan April', 9200000.00),
(2, 4, 'pengeluaran', DATE '2026-04-27', 'Makan siang keluarga', 62000.00);
INSERT INTO anggaran (kategori_id, periode, batas_jumlah) VALUES
( 4, DATE '2026-04-01', 400000.00),
( 5, DATE '2026-04-01', 600000.00),
( 7, DATE '2026-04-01', 150000.00),
( 8, DATE '2026-04-01', 50000.00),
(10, DATE '2026-04-01', 2500000.00),
(11, DATE '2026-04-01', 350000.00),
(12, DATE '2026-04-01', 350000.00),
(14, DATE '2026-04-01', 150000.00),
(15, DATE '2026-04-01', 300000.00);
-- Papan anggaran Maret vs April dalam satu pass agregasi:
-- FILTER memilah bulan tanpa memecah GROUP BY kategori.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas_maret,
COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-03-01'
AND t.tanggal < DATE '2026-04-01'), 0) AS aktual_maret,
a.batas_jumlah
- COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-03-01'
AND t.tanggal < DATE '2026-04-01'), 0) AS sisa_maret,
COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-04-01'
AND t.tanggal < DATE '2026-05-01'), 0) AS aktual_april
FROM kategori k
LEFT JOIN anggaran a ON a.kategori_id = k.id AND a.periode = DATE '2026-03-01'
LEFT JOIN transaksi t ON t.kategori_id = k.id
WHERE k.jenis = 'pengeluaran'
GROUP BY k.id, k.nama, a.batas_jumlah
HAVING count(a.id) > 0 OR count(t.id) > 0
ORDER BY k.nama;
-- Deteksi pelanggaran: hanya kategori yang melampaui batas Maret.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas,
sum(t.jumlah) AS aktual,
sum(t.jumlah) - a.batas_jumlah AS lebih
FROM anggaran a
JOIN kategori k ON k.id = a.kategori_id
JOIN transaksi t ON t.kategori_id = k.id
WHERE a.periode = DATE '2026-03-01'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY k.nama, a.batas_jumlah
HAVING sum(t.jumlah) > a.batas_jumlah
ORDER BY lebih DESC;
-- Sisa anggaran April: bulan setelah pelanggaran berlangsung normal kembali.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas,
COALESCE(sum(t.jumlah), 0) AS aktual,
a.batas_jumlah - COALESCE(sum(t.jumlah), 0) AS sisa,
round(COALESCE(sum(t.jumlah), 0) * 100 / a.batas_jumlah, 1) AS terpakai_persen
FROM anggaran a
JOIN kategori k ON k.id = a.kategori_id
LEFT JOIN transaksi t ON t.kategori_id = k.id
AND t.tanggal >= DATE '2026-04-01' AND t.tanggal < DATE '2026-05-01'
WHERE a.periode = DATE '2026-04-01'
GROUP BY k.nama, a.batas_jumlah
ORDER BY sisa;Output yang Dihasilkan:
INSERT 0 9
INSERT 0 11
INSERT 0 9
kategori | batas_maret | aktual_maret | sisa_maret | aktual_april
---------------------+-------------+--------------+------------+--------------
bensin | 150000.00 | 120000.00 | 30000.00 | 120000.00
internet | 350000.00 | 350000.00 | 0.00 | 350000.00
kesehatan | | 185000.00 | | 0
listrik | 350000.00 | 310000.00 | 40000.00 | 310000.00
makan di luar | 400000.00 | 454000.00 | -54000.00 | 107000.00
makan di rumah | 1000000.00 | 985000.00 | 15000.00 | 480000.00
parkir dan tol | 50000.00 | 26000.00 | 24000.00 | 0
rekreasi | 300000.00 | 450000.00 | -150000.00 | 0
sewa tempat tinggal | 2500000.00 | 2500000.00 | 0.00 | 2500000.00
streaming | 150000.00 | 120000.00 | 30000.00 | 120000.00
(10 rows)
kategori | batas | aktual | lebih
---------------+-----------+-----------+-----------
rekreasi | 300000.00 | 450000.00 | 150000.00
makan di luar | 400000.00 | 454000.00 | 54000.00
(2 rows)
kategori | batas | aktual | sisa | terpakai_persen
---------------------+------------+------------+-----------+-----------------
sewa tempat tinggal | 2500000.00 | 2500000.00 | 0.00 | 100.0
internet | 350000.00 | 350000.00 | 0.00 | 100.0
bensin | 150000.00 | 120000.00 | 30000.00 | 80.0
streaming | 150000.00 | 120000.00 | 30000.00 | 80.0
listrik | 350000.00 | 310000.00 | 40000.00 | 88.6
parkir dan tol | 50000.00 | 0 | 50000.00 | 0.0
makan di rumah | 600000.00 | 480000.00 | 120000.00 | 80.0
makan di luar | 400000.00 | 107000.00 | 293000.00 | 26.8
rekreasi | 300000.00 | 0 | 300000.00 | 0.0
(9 rows)Bedah tiga query ini satu per satu:
- Papan dua bulan dalam satu pass: dua kolom
FILTER—WHERE t.tanggal >= '2026-03-01' AND < '2026-04-01'untuk Maret, rentang April untuk kolom berikutnya — memilah bulan tanpa memecahGROUP BY kategori. Satu kali scan, dua laporan bulanan sekaligus.HAVING count(a.id) > 0 OR count(t.id) > 0menyaring kategori yang tidak punya anggaran dan tidak punya transaksi, sehingga induk sepertimakan(yang tak pernah dipakai langsung) tidak menambah noise;kesehatanjustru tetap tampil sebagai contoh pengeluaran tanpa anggaran denganbatas_maretkosong. - Deteksi pelanggaran dengan
HAVING: kondisi agregatsum(t.jumlah) > a.batas_jumlahhanya meloloskan kategori yang melampaui batas. Hasilnya dua baris —rekreasilebih Rp150.000 danmakan di luarlebih Rp54.000. Kolomlebihmenjawab “lebih berapa” langsung. - Sisa anggaran April: query ketiga mengulang pola yang sama untuk periode berikutnya dengan persentase terpakai. April pulih:
makan di luarhanya memakai 26,8% dari batas, sementaraparkir dan toldanrekreasibenar-benar tidak terpakai (0) karenaCOALESCEmengubah NULL menjadi nol.
Pelajaran bulan Ramadhan terbaca jelas dari angka: pelanggaran terjadi bukan pada kategori terbesar (tagihan) melainkan pada kategori yang paling mudah membengkak tanpa terasa — makan di luar dan rekreasi. Persis untuk itulah anggaran per kategori, bukan anggaran total bulanan, yang dipasang.
Anggaran Duplikat #
Terakhir, bukti bahwa satu kategori hanya boleh punya satu anggaran per periode. Pencatatan ulang anggaran makan di luar untuk April — mungkin karena lupa sudah pernah menetapkannya — harus ditolak:
-- anggaran-duplikat.sql — satu kategori hanya boleh punya satu anggaran
-- per periode; INSERT kedua untuk kombinasi yang sama harus ditolak UNIQUE.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (4, DATE '2026-04-01', 500000.00);Output yang Dihasilkan:
psql:anggaran-duplikat.sql:5: ERROR: duplicate key value violates unique constraint "satu_anggaran_per_periode"
DETAIL: Key (kategori_id, periode)=(4, 2026-04-01) already exists.Pesan error menyebut constraint satu_anggaran_per_periode dengan pasangan (kategori_id, periode) = (4, 2026-04-01). Di aplikasi nyata, konflik ini adalah sinyal untuk beralih ke INSERT ... ON CONFLICT DO UPDATE bila maksudnya memperbarui batas — bukan menambah baris kedua yang membuat laporan anggaran mendadak bercabang dua.
Pohon kategori dan anggaran kini bekerja sama menjaga disiplin bulanan. Satu hal belum terjawab: bagaimana keuangan Budi bergerak dalam hitungan bulan dan tahun? Bagian 4 menjawabnya dengan laporan — arus kas bulanan yang tidak kehilangan bulan kosong, laba-rugi sederhana, posisi keuangan kumulatif per akun, dan tren year-over-year antar kategori.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
anggaran-duplikat.sql
Unduh berkas-- anggaran-duplikat.sql — satu kategori hanya boleh punya satu anggaran
-- per periode; INSERT kedua untuk kombinasi yang sama harus ditolak UNIQUE.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (4, DATE '2026-04-01', 500000.00);
anggaran-vs-actual.sql
Unduh berkas-- anggaran-vs-actual.sql — anggaran bulanan Maret dan April, transaksi April,
-- lalu perbandingan anggaran vs pengeluaran nyata dengan FILTER dan HAVING.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah) VALUES
( 4, DATE '2026-03-01', 400000.00), -- makan di luar
( 5, DATE '2026-03-01', 1000000.00), -- makan di rumah
( 7, DATE '2026-03-01', 150000.00), -- bensin
( 8, DATE '2026-03-01', 50000.00), -- parkir dan tol
(10, DATE '2026-03-01', 2500000.00), -- sewa tempat tinggal
(11, DATE '2026-03-01', 350000.00), -- listrik
(12, DATE '2026-03-01', 350000.00), -- internet
(14, DATE '2026-03-01', 150000.00), -- streaming
(15, DATE '2026-03-01', 300000.00); -- rekreasi
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
(1, 10, 'pengeluaran', DATE '2026-04-01', 'Sewa tempat tinggal April', 2500000.00),
(1, 12, 'pengeluaran', DATE '2026-04-05', 'Internet bulanan', 350000.00),
(3, 7, 'pengeluaran', DATE '2026-04-08', 'Isi bensin (QRIS)', 60000.00),
(1, 14, 'pengeluaran', DATE '2026-04-10', 'Langganan streaming', 120000.00),
(3, 4, 'pengeluaran', DATE '2026-04-13', 'Kopi sore', 45000.00),
(1, 11, 'pengeluaran', DATE '2026-04-15', 'Listrik April', 310000.00),
(3, 2, 'pemasukan', DATE '2026-04-18', 'Proyek desain lepas', 900000.00),
(1, 5, 'pengeluaran', DATE '2026-04-20', 'Belanja dapur seminggu', 480000.00),
(3, 7, 'pengeluaran', DATE '2026-04-22', 'Isi bensin (QRIS)', 60000.00),
(1, 1, 'pemasukan', DATE '2026-04-25', 'Gaji bulanan April', 9200000.00),
(2, 4, 'pengeluaran', DATE '2026-04-27', 'Makan siang keluarga', 62000.00);
INSERT INTO anggaran (kategori_id, periode, batas_jumlah) VALUES
( 4, DATE '2026-04-01', 400000.00),
( 5, DATE '2026-04-01', 600000.00),
( 7, DATE '2026-04-01', 150000.00),
( 8, DATE '2026-04-01', 50000.00),
(10, DATE '2026-04-01', 2500000.00),
(11, DATE '2026-04-01', 350000.00),
(12, DATE '2026-04-01', 350000.00),
(14, DATE '2026-04-01', 150000.00),
(15, DATE '2026-04-01', 300000.00);
-- Papan anggaran Maret vs April dalam satu pass agregasi:
-- FILTER memilah bulan tanpa memecah GROUP BY kategori.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas_maret,
COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-03-01'
AND t.tanggal < DATE '2026-04-01'), 0) AS aktual_maret,
a.batas_jumlah
- COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-03-01'
AND t.tanggal < DATE '2026-04-01'), 0) AS sisa_maret,
COALESCE(sum(t.jumlah) FILTER (WHERE t.tanggal >= DATE '2026-04-01'
AND t.tanggal < DATE '2026-05-01'), 0) AS aktual_april
FROM kategori k
LEFT JOIN anggaran a ON a.kategori_id = k.id AND a.periode = DATE '2026-03-01'
LEFT JOIN transaksi t ON t.kategori_id = k.id
WHERE k.jenis = 'pengeluaran'
GROUP BY k.id, k.nama, a.batas_jumlah
HAVING count(a.id) > 0 OR count(t.id) > 0
ORDER BY k.nama;
-- Deteksi pelanggaran: hanya kategori yang melampaui batas Maret.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas,
sum(t.jumlah) AS aktual,
sum(t.jumlah) - a.batas_jumlah AS lebih
FROM anggaran a
JOIN kategori k ON k.id = a.kategori_id
JOIN transaksi t ON t.kategori_id = k.id
WHERE a.periode = DATE '2026-03-01'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY k.nama, a.batas_jumlah
HAVING sum(t.jumlah) > a.batas_jumlah
ORDER BY lebih DESC;
-- Sisa anggaran April: bulan setelah pelanggaran berlangsung normal kembali.
SELECT k.nama AS kategori,
a.batas_jumlah AS batas,
COALESCE(sum(t.jumlah), 0) AS aktual,
a.batas_jumlah - COALESCE(sum(t.jumlah), 0) AS sisa,
round(COALESCE(sum(t.jumlah), 0) * 100 / a.batas_jumlah, 1) AS terpakai_persen
FROM anggaran a
JOIN kategori k ON k.id = a.kategori_id
LEFT JOIN transaksi t ON t.kategori_id = k.id
AND t.tanggal >= DATE '2026-04-01' AND t.tanggal < DATE '2026-05-01'
WHERE a.periode = DATE '2026-04-01'
GROUP BY k.nama, a.batas_jumlah
ORDER BY sisa;
isi-kategori.sql
Unduh berkas-- isi-kategori.sql — pohon kategori lengkap: pemasukan dan pengeluaran.
-- Dua tingkat: induk (makan, transportasi, tagihan, hiburan) dan anak
-- (makan di luar, bensin, listrik, streaming, dan seterusnya).
INSERT INTO kategori (nama, jenis, parent_id) VALUES
('gaji', 'pemasukan', NULL), -- 1
('pemasukan lain', 'pemasukan', NULL), -- 2
('makan', 'pengeluaran', NULL), -- 3
('makan di luar', 'pengeluaran', 3), -- 4
('makan di rumah', 'pengeluaran', 3), -- 5
('transportasi', 'pengeluaran', NULL), -- 6
('bensin', 'pengeluaran', 6), -- 7
('parkir dan tol', 'pengeluaran', 6), -- 8
('tagihan', 'pengeluaran', NULL), -- 9
('sewa tempat tinggal', 'pengeluaran', 9), -- 10
('listrik', 'pengeluaran', 9), -- 11
('internet', 'pengeluaran', 9), -- 12
('hiburan', 'pengeluaran', NULL), -- 13
('streaming', 'pengeluaran', 13), -- 14
('rekreasi', 'pengeluaran', 13), -- 15
('kesehatan', 'pengeluaran', NULL); -- 16
-- Pohon kategori dilihat lewat self-join: tiap baris berpasangan dengan induknya.
SELECT k.id, k.nama, k.jenis, COALESCE(p.nama, '-') AS induk
FROM kategori k
LEFT JOIN kategori p ON p.id = k.parent_id
ORDER BY k.id;
rollup-kategori.sql
Unduh berkas-- rollup-kategori.sql — akun, transaksi Maret 2026, lalu pengeluaran
-- diringkas dari subkategori ke kategori utama dengan self-join dan ROLLUP.
INSERT INTO akun (nama, jenis, saldo_awal) VALUES
('Rekening Gaji', 'rekening', 4200000.00),
('Kas', 'kas', 750000.00),
('Dompet Digital', 'dompet digital', 600000.00);
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
(1, 10, 'pengeluaran', DATE '2026-03-01', 'Sewa tempat tinggal Maret', 2500000.00),
(2, 8, 'pengeluaran', DATE '2026-03-03', 'Parkir kantor', 8000.00),
(1, 12, 'pengeluaran', DATE '2026-03-05', 'Internet bulanan', 350000.00),
(3, 7, 'pengeluaran', DATE '2026-03-08', 'Isi bensin (QRIS)', 60000.00),
(2, 4, 'pengeluaran', DATE '2026-03-08', 'Makan siang warteg', 25000.00),
(1, 14, 'pengeluaran', DATE '2026-03-10', 'Langganan streaming', 120000.00),
(1, 5, 'pengeluaran', DATE '2026-03-11', 'Belanja dapur seminggu', 520000.00),
(3, 4, 'pengeluaran', DATE '2026-03-13', 'Kopi dengan teman', 78000.00),
(1, 11, 'pengeluaran', DATE '2026-03-15', 'Listrik Maret', 310000.00),
(3, 4, 'pengeluaran', DATE '2026-03-17', 'Brunch akhir pekan', 96000.00),
(2, 16, 'pengeluaran', DATE '2026-03-19', 'Obat dan vitamin', 185000.00),
(3, 4, 'pengeluaran', DATE '2026-03-20', 'Buka puasa di warung', 62000.00),
(1, 5, 'pengeluaran', DATE '2026-03-21', 'Belanja dapur Ramadhan', 465000.00),
(3, 7, 'pengeluaran', DATE '2026-03-22', 'Isi bensin (QRIS)', 60000.00),
(3, 4, 'pengeluaran', DATE '2026-03-24', 'Snack rapat komunitas', 45000.00),
(1, 1, 'pemasukan', DATE '2026-03-25', 'Gaji bulanan Maret', 9200000.00),
(3, 4, 'pengeluaran', DATE '2026-03-27', 'Buka puasa keluarga', 148000.00),
(2, 8, 'pengeluaran', DATE '2026-03-28', 'Parkir dan tol perjalanan', 10000.00),
(1, 15, 'pengeluaran', DATE '2026-03-29', 'Wisata keluarga akhir bulan', 450000.00),
(2, 8, 'pengeluaran', DATE '2026-03-30', 'Parkir kantor', 8000.00);
-- Rincian pengeluaran Maret per subkategori, digulung ke kategori utama.
-- COALESCE(p.nama, k.nama) menjadikan kategori tanpa induk (kesehatan)
-- sebagai kategori utama bagi dirinya sendiri.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
k.nama AS subkategori,
count(*) AS transaksi,
sum(t.jumlah) AS total
FROM transaksi t
JOIN kategori k ON k.id = t.kategori_id
LEFT JOIN kategori p ON p.id = k.parent_id
WHERE t.tipe = 'pengeluaran'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY COALESCE(p.nama, k.nama), k.nama
ORDER BY kategori_utama, total DESC;
-- ROLLUP: baris subtotal per kategori utama dan total keseluruhan
-- ditambahkan otomatis; GROUPING() menandai baris hasil gulungan.
SELECT CASE WHEN GROUPING(p.nama) = 1 THEN 'TOTAL PENGELUARAN'
ELSE p.nama END AS kategori_utama,
CASE WHEN GROUPING(k.nama) = 1 THEN '-- subtotal --'
ELSE k.nama END AS subkategori,
count(*) AS transaksi,
sum(t.jumlah) AS total
FROM transaksi t
JOIN kategori k ON k.id = t.kategori_id
JOIN kategori p ON p.id = k.parent_id
WHERE t.tipe = 'pengeluaran'
AND t.tanggal >= DATE '2026-03-01' AND t.tanggal < DATE '2026-04-01'
GROUP BY ROLLUP (p.nama, k.nama)
ORDER BY p.nama NULLS LAST, GROUPING(k.nama), k.nama;
siapkan-tabel.sql
Unduh berkas-- siapkan-tabel.sql — skema keuangan pribadi, versi ringkas untuk bagian ini.
-- Definisi identik dengan Bagian 1; setiap bagian membangun ulang dari nol.
CREATE TYPE tipe_arus_kas AS ENUM ('pemasukan', 'pengeluaran');
CREATE TABLE akun (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL UNIQUE,
jenis text NOT NULL CHECK (jenis IN ('kas', 'rekening', 'dompet digital')),
saldo_awal numeric(14,2) NOT NULL CHECK (saldo_awal >= 0)
);
CREATE TABLE kategori (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
nama text NOT NULL,
jenis tipe_arus_kas NOT NULL,
parent_id integer,
CONSTRAINT induk_sama_jenis
FOREIGN KEY (parent_id, jenis) REFERENCES kategori (id, jenis),
CONSTRAINT bukan_induk_diri_sendiri
CHECK (parent_id IS NULL OR parent_id <> id),
CONSTRAINT pasangan_id_jenis_unik UNIQUE (id, jenis)
);
CREATE TABLE transaksi (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
akun_id integer NOT NULL REFERENCES akun (id),
kategori_id integer NOT NULL,
tipe tipe_arus_kas NOT NULL,
tanggal date NOT NULL,
deskripsi text NOT NULL,
jumlah numeric(14,2) NOT NULL,
CONSTRAINT jumlah_harus_positif CHECK (jumlah > 0),
CONSTRAINT tipe_cocok_dengan_kategori
FOREIGN KEY (kategori_id, tipe) REFERENCES kategori (id, jenis)
);
CREATE TABLE anggaran (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kategori_id integer NOT NULL REFERENCES kategori (id),
periode date NOT NULL,
batas_jumlah numeric(14,2) NOT NULL,
CONSTRAINT batas_harus_positif CHECK (batas_jumlah > 0),
CONSTRAINT periode_awal_bulan CHECK (periode = date_trunc('month', periode)::date),
CONSTRAINT satu_anggaran_per_periode UNIQUE (kategori_id, periode)
);
CREATE INDEX idx_transaksi_akun_tanggal ON transaksi (akun_id, tanggal);
CREATE INDEX idx_transaksi_kategori ON transaksi (kategori_id);
CREATE INDEX idx_transaksi_tanggal ON transaksi (tanggal);