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:

text
CREATE TYPE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE INDEX

Pohon 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:

text
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:

text
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:

  1. Query pertama memakai COALESCE(p.nama, k.nama): subkategori yang punya induk digulung ke induknya, sedangkan kesehatan yang tidak punya induk menjadi kategori utama bagi dirinya sendiri. Hasilnya: rincian sepuluh baris siap dibaca — dari sewa tempat tinggal Rp2.500.000 sampai parkir dan tol Rp26.000.
  2. Query kedua memakai GROUP BY ROLLUP (p.nama, k.nama): PostgreSQL otomatis menambahkan baris subtotal per kategori utama dan satu baris total keseluruhan. Fungsi GROUPING(kolom) mengembalikan 1 pada baris hasil gulungan — itulah yang mengubah label menjadi -- subtotal -- dan TOTAL PENGELUARAN.
  3. ORDER BY p.nama NULLS LAST, GROUPING(k.nama), k.nama menjaga urutan yang manusiawi: kategori utama alfabetis, baris subtotal selalu di bawah rinciannya, dan total keseluruhan menutup laporan. Tanpa NULLS LAST, baris total (yang p.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:

text
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:

  1. 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 memecah GROUP BY kategori. Satu kali scan, dua laporan bulanan sekaligus. HAVING count(a.id) > 0 OR count(t.id) > 0 menyaring kategori yang tidak punya anggaran dan tidak punya transaksi, sehingga induk seperti makan (yang tak pernah dipakai langsung) tidak menambah noise; kesehatan justru tetap tampil sebagai contoh pengeluaran tanpa anggaran dengan batas_maret kosong.
  2. Deteksi pelanggaran dengan HAVING: kondisi agregat sum(t.jumlah) > a.batas_jumlah hanya meloloskan kategori yang melampaui batas. Hasilnya dua baris — rekreasi lebih Rp150.000 dan makan di luar lebih Rp54.000. Kolom lebih menjawab “lebih berapa” langsung.
  3. Sisa anggaran April: query ketiga mengulang pola yang sama untuk periode berikutnya dengan persentase terpakai. April pulih: makan di luar hanya memakai 26,8% dari batas, sementara parkir dan tol dan rekreasi benar-benar tidak terpakai (0) karena COALESCE mengubah 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:

text
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
sql
-- 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
sql
-- 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
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;

rollup-kategori.sql

Unduh berkas
sql
-- 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
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);