Register yang sehat menghasilkan sesuatu yang lebih berharga dari sekadar saldo: perspektif waktu. Tujuh belas bulan pencatatan menyimpan jawaban atas pertanyaan yang hanya bisa dijawab melalui agregasi — berapa selisih pemasukan dan pengeluaran tiap bulan, bulan mana yang paling boros, bagaimana posisi keuangan tiap akun bergerak, dan kategori apa yang tumbuh paling cepat tahun ke tahun. Pada bagian penutup ini kita membangun laporan-laporan itu: arus kas bulanan yang tetap menampilkan bulan kosong berkat generate_series, laba-rugi sederhana, posisi keuangan kumulatif per akun, dan tren year-over-year.

Menyiapkan Tabel #

Empat tabel dibangun ulang versi ringkas — tanpa COMMENT ON, karena bagian ini fokus pada pembacaan data. 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

Riwayat Tujuh Belas Bulan #

Berkas pembuka membangun ulang empat tabel versi ringkas, lalu mengisi riwayat Februari 2025 sampai Juni 2026. Januari 2025 sengaja dibiarkan kosong — bulan sebelum Budi mulai mencatat — untuk keperluan demonstrasi di laporan berikutnya. Transaksi rutin (gaji, sewa, internet, listrik, dapur, streaming, bensin) dibangkitkan dengan INSERT ... SELECT dari generate_series; transaksi insidental ditulis manual:

sql isi-data.sql
-- isi-data.sql — riwayat 17 bulan: Februari 2025 (bulan pertama pencatatan)
-- sampai Juni 2026. Januari 2025 sengaja kosong: bulan sebelum Budi mulai
-- mencatat. Transaksi rutin dibangkitkan dengan generate_series; transaksi
-- insidental ditulis satu per satu.

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 kategori (nama, jenis, parent_id) VALUES
    ('gaji',                'pemasukan',  NULL),
    ('pemasukan lain',      'pemasukan',  NULL),
    ('makan',               'pengeluaran', NULL),
    ('makan di luar',       'pengeluaran', 3),
    ('makan di rumah',      'pengeluaran', 3),
    ('transportasi',        'pengeluaran', NULL),
    ('bensin',              'pengeluaran', 6),
    ('parkir dan tol',      'pengeluaran', 6),
    ('tagihan',             'pengeluaran', NULL),
    ('sewa tempat tinggal', 'pengeluaran', 9),
    ('listrik',             'pengeluaran', 9),
    ('internet',            'pengeluaran', 9),
    ('hiburan',             'pengeluaran', NULL),
    ('streaming',           'pengeluaran', 13),
    ('rekreasi',            'pengeluaran', 13),
    ('kesehatan',           'pengeluaran', NULL);

-- Gaji tanggal 25 setiap bulan; naik menjadi 9200000 mulai 2026.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 1, 'pemasukan', g.tanggal,
       'Gaji bulanan ' || to_char(g.tanggal, 'YYYY-MM'),
       CASE WHEN g.tanggal >= DATE '2026-01-01' THEN 9200000 ELSE 8500000 END
FROM generate_series(DATE '2025-02-25', DATE '2026-06-25', interval '1 month') AS g(tanggal);

-- Sewa tempat tinggal tanggal 1 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 10, 'pengeluaran', g.tanggal, 'Sewa tempat tinggal', 2500000
FROM generate_series(DATE '2025-02-01', DATE '2026-06-01', interval '1 month') AS g(tanggal);

-- Internet tanggal 5 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 12, 'pengeluaran', g.tanggal, 'Internet bulanan', 350000
FROM generate_series(DATE '2025-02-05', DATE '2026-06-05', interval '1 month') AS g(tanggal);

-- Belanja dapur tanggal 6 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 5, 'pengeluaran', g.tanggal, 'Belanja dapur bulanan', 650000
FROM generate_series(DATE '2025-02-06', DATE '2026-06-06', interval '1 month') AS g(tanggal);

-- Listrik tanggal 15: lebih mahal pada bulan terik (Maret-Oktober).
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 11, 'pengeluaran', g.tanggal, 'Tagihan listrik',
       CASE WHEN extract(month FROM g.tanggal)::int BETWEEN 3 AND 10
            THEN 310000 ELSE 260000 END
FROM generate_series(DATE '2025-02-15', DATE '2026-06-15', interval '1 month') AS g(tanggal);

-- Langganan streaming tanggal 10, autodebet dari rekening.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 14, 'pengeluaran', g.tanggal, 'Langganan streaming (autodebet)', 120000
FROM generate_series(DATE '2025-02-10', DATE '2026-06-10', interval '1 month') AS g(tanggal);

-- Isi bensin tanggal 8, dibayar QRIS dari dompet digital.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 3, 7, 'pengeluaran', g.tanggal, 'Isi bensin (QRIS)', 60000
FROM generate_series(DATE '2025-02-08', DATE '2026-06-08', interval '1 month') AS g(tanggal);

-- Pendapatan sampingan: proyek desain lepas, masuk ke dompet digital.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (3, 2, 'pemasukan', DATE '2025-05-16', 'Proyek desain lepas', 400000.00),
    (3, 2, 'pemasukan', DATE '2025-08-21', 'Proyek desain lepas', 350000.00),
    (3, 2, 'pemasukan', DATE '2025-10-18', 'Proyek desain lepas', 500000.00),
    (3, 2, 'pemasukan', DATE '2025-12-12', 'Proyek desain lepas', 600000.00),
    (3, 2, 'pemasukan', DATE '2026-02-14', 'Proyek desain lepas', 600000.00),
    (3, 2, 'pemasukan', DATE '2026-03-20', 'Proyek desain lepas', 750000.00),
    (3, 2, 'pemasukan', DATE '2026-04-18', 'Proyek desain lepas', 900000.00);

-- Makan di luar: mayoritas QRIS dari dompet digital, sebagian tunai.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (3, 4, 'pengeluaran', DATE '2025-02-14', 'Makan malam akhir pekan',       85000.00),
    (2, 4, 'pengeluaran', DATE '2025-03-21', 'Makan siang keluarga besar',    62000.00),
    (3, 4, 'pengeluaran', DATE '2025-05-10', 'Kopi dan kue',                  45000.00),
    (3, 4, 'pengeluaran', DATE '2025-06-27', 'Brunch akhir pekan',            96000.00),
    (3, 4, 'pengeluaran', DATE '2025-08-15', 'Makan malam perayaan',          78000.00),
    (2, 4, 'pengeluaran', DATE '2025-09-05', 'Makan siang warteg',            25000.00),
    (3, 4, 'pengeluaran', DATE '2025-10-17', 'Kopi sore',                     54000.00),
    (3, 4, 'pengeluaran', DATE '2025-11-08', 'Makan malam ulang tahun',       68000.00),
    (3, 4, 'pengeluaran', DATE '2025-12-20', 'Makan keluarga akhir tahun',   148000.00),
    (3, 4, 'pengeluaran', DATE '2026-01-03', 'Makan malam tahun baru',        88000.00),
    (3, 4, 'pengeluaran', DATE '2026-02-21', 'Makan malam Valentine',        115000.00),
    (3, 4, 'pengeluaran', DATE '2026-03-27', 'Buka puasa keluarga',          148000.00),
    (2, 4, 'pengeluaran', DATE '2026-04-24', 'Makan siang keluarga',          62000.00),
    (3, 4, 'pengeluaran', DATE '2026-05-15', 'Brunch akhir pekan',            96000.00),
    (3, 4, 'pengeluaran', DATE '2026-06-19', 'Makan malam perayaan libur',    74000.00);

-- Parkir dan tol tunai, serta rekreasi dan kesehatan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (2,  8, 'pengeluaran', DATE '2025-04-19', 'Tol akhir pekan',             18000.00),
    (2,  8, 'pengeluaran', DATE '2025-08-22', 'Parkir mal',                  15000.00),
    (2,  8, 'pengeluaran', DATE '2025-12-31', 'Parkir tengah malam',         20000.00),
    (2,  8, 'pengeluaran', DATE '2026-03-28', 'Tol perjalanan',              10000.00),
    (2,  8, 'pengeluaran', DATE '2026-05-02', 'Parkir acara keluarga',       12000.00),
    (2,  8, 'pengeluaran', DATE '2026-06-20', 'Parkir stadion',              14000.00),
    (1, 15, 'pengeluaran', DATE '2025-04-12', 'Liburan akhir pekan',        380000.00),
    (1, 15, 'pengeluaran', DATE '2025-07-19', 'Liburan musim libur',        410000.00),
    (1, 15, 'pengeluaran', DATE '2025-12-28', 'Liburan akhir tahun',        520000.00),
    (3, 15, 'pengeluaran', DATE '2026-02-07', 'Pendakian akhir pekan',      320000.00),
    (1, 15, 'pengeluaran', DATE '2026-04-11', 'Wisata keluarga',            450000.00),
    (3, 16, 'pengeluaran', DATE '2025-05-09', 'Vitamin keluarga',           150000.00),
    (3, 16, 'pengeluaran', DATE '2025-11-21', 'Obat dan konsultasi',        220000.00),
    (3, 16, 'pengeluaran', DATE '2026-02-19', 'Obat flu',                   185000.00);

-- Belanja dapur ekstra di bulan perayaan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (1, 5, 'pengeluaran', DATE '2025-12-14', 'Belanja bahan Natal',    350000.00),
    (1, 5, 'pengeluaran', DATE '2026-03-21', 'Belanja Ramadhan',       465000.00);

-- Ringkasan muat data: satu baris untuk seluruh register.
SELECT count(*) AS jumlah_transaksi,
       min(tanggal) AS transaksi_pertama,
       max(tanggal) AS transaksi_terakhir,
       sum(jumlah) FILTER (WHERE tipe = 'pemasukan')  AS total_pemasukan,
       sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS total_pengeluaran
FROM transaksi;

-- Sebaran transaksi per kategori utama.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
       k.jenis,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total
FROM kategori k
LEFT JOIN transaksi t ON t.kategori_id = k.id
LEFT JOIN kategori p ON p.id = k.parent_id
GROUP BY COALESCE(p.nama, k.nama), k.jenis
ORDER BY k.jenis, kategori_utama;

Output yang Dihasilkan:

text
INSERT 0 3
INSERT 0 16
INSERT 0 17
INSERT 0 17
INSERT 0 17
INSERT 0 17
INSERT 0 17
INSERT 0 17
INSERT 0 17
INSERT 0 7
INSERT 0 15
INSERT 0 14
INSERT 0 2
 jumlah_transaksi | transaksi_pertama | transaksi_terakhir | total_pemasukan | total_pengeluaran 
------------------+-------------------+--------------------+-----------------+-------------------
              157 | 2025-02-01        | 2026-06-25         |    152800000.00 |       72363000.00
(1 row)

 kategori_utama |    jenis    | transaksi |    total     
----------------+-------------+-----------+--------------
 gaji           | pemasukan   |        17 | 148700000.00
 pemasukan lain | pemasukan   |         7 |   4100000.00
 hiburan        | pengeluaran |        22 |   4120000.00
 kesehatan      | pengeluaran |         3 |    555000.00
 makan          | pengeluaran |        34 |  13109000.00
 tagihan        | pengeluaran |        51 |  53470000.00
 transportasi   | pengeluaran |        23 |   1109000.00
(7 rows)

Beberapa keputusan pada berkas ini:

  1. generate_series(DATE '2025-02-25', DATE '2026-06-25', interval '1 month') menghasilkan satu baris per bulan — 17 baris gaji tanpa menulis 17 baris INSERT. Kolom tanggalnya dipakai sekaligus sebagai tanggal transaksi, dan CASE WHEN g.tanggal >= DATE '2026-01-01' menaikkan gaji menjadi Rp9.200.000 mulai 2026.
  2. Listrik musiman lewat extract(month ...) BETWEEN 3 AND 10: tagihan Rp310.000 pada bulan terik, Rp260.000 di sisinya — pola yang nanti terbaca sebagai stabilitas kategori tagihan di laporan tahunan.
  3. Ringkasan muat data memakai FILTER dalam satu baris: 157 transaksi, pemasukan Rp152.800.000, pengeluaran Rp72.363.000. Query kedua memverifikasi sebaran per kategori utama: tagihan mendominasi (Rp53.470.000), disusul makan (Rp13.109.000).

Arus Kas Bulanan Tanpa Bulan Hilang #

Agregasi langsung atas transaksi hanya menghasilkan bulan yang ada transaksinya — Januari 2025 akan hilang tanpa kabar, padahal kekosongannya adalah informasi (bulan sebelum pencatatan dimulai). generate_series membuat kalendernya lebih dulu, lalu data nyata di-LEFT JOIN kepadanya:

-- arus-kas-bulanan.sql — pemasukan, pengeluaran, selisih, dan kumulatif
-- per bulan. generate_series membuat kalender 18 bulan sehingga bulan tanpa
-- transaksi (Januari 2025) tetap tampil; FILTER memilah arus dalam satu pass.

WITH bulan AS (
    SELECT generate_series(DATE '2025-01-01', DATE '2026-06-01', interval '1 month')::date
           AS awal_bulan
),
arus AS (
    SELECT date_trunc('month', t.tanggal)::date AS awal_bulan,
           sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan')  AS pemasukan,
           sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran') AS pengeluaran
    FROM transaksi t
    GROUP BY 1
)
SELECT to_char(b.awal_bulan, 'YYYY-MM') AS bulan,
       COALESCE(a.pemasukan, 0)::numeric(14,2)   AS pemasukan,
       COALESCE(a.pengeluaran, 0)::numeric(14,2) AS pengeluaran,
       (COALESCE(a.pemasukan, 0) - COALESCE(a.pengeluaran, 0))::numeric(14,2) AS selisih,
       sum(COALESCE(a.pemasukan, 0) - COALESCE(a.pengeluaran, 0))
           OVER (ORDER BY b.awal_bulan)::numeric(14,2) AS kumulatif
FROM bulan b
LEFT JOIN arus a ON a.awal_bulan = b.awal_bulan
ORDER BY b.awal_bulan;

Output yang Dihasilkan:

text
  bulan  |  pemasukan  | pengeluaran |  selisih   |  kumulatif  
---------+-------------+-------------+------------+-------------
 2025-01 |        0.00 |        0.00 |       0.00 |        0.00
 2025-02 |  8500000.00 |  4025000.00 | 4475000.00 |  4475000.00
 2025-03 |  8500000.00 |  4052000.00 | 4448000.00 |  8923000.00
 2025-04 |  8500000.00 |  4388000.00 | 4112000.00 | 13035000.00
 2025-05 |  8900000.00 |  4185000.00 | 4715000.00 | 17750000.00
 2025-06 |  8500000.00 |  4086000.00 | 4414000.00 | 22164000.00
 2025-07 |  8500000.00 |  4400000.00 | 4100000.00 | 26264000.00
 2025-08 |  8850000.00 |  4083000.00 | 4767000.00 | 31031000.00
 2025-09 |  8500000.00 |  4015000.00 | 4485000.00 | 35516000.00
 2025-10 |  9000000.00 |  4044000.00 | 4956000.00 | 40472000.00
 2025-11 |  8500000.00 |  4228000.00 | 4272000.00 | 44744000.00
 2025-12 |  9100000.00 |  4978000.00 | 4122000.00 | 48866000.00
 2026-01 |  9200000.00 |  4028000.00 | 5172000.00 | 54038000.00
 2026-02 |  9800000.00 |  4560000.00 | 5240000.00 | 59278000.00
 2026-03 |  9950000.00 |  4613000.00 | 5337000.00 | 64615000.00
 2026-04 | 10100000.00 |  4502000.00 | 5598000.00 | 70213000.00
 2026-05 |  9200000.00 |  4098000.00 | 5102000.00 | 75315000.00
 2026-06 |  9200000.00 |  4078000.00 | 5122000.00 | 80437000.00
(18 rows)

Cara kerjanya per lapis:

  1. bulan memanggil generate_series dengan interval '1 month' — 18 baris awal bulan dari Januari 2025 sampai Juni 2026, terlepas dari ada tidaknya transaksi.
  2. arus mengagregasi data nyata per bulan dengan dua kolom FILTER: pemasukan dan pengeluaran dipilah dalam satu pass, dan date_trunc('month', ...) memotong tanggal ke awal bulan agar bisa disandingkan dengan kalender.
  3. LEFT JOIN + COALESCE(..., 0) mengubah ketiadaan pasangan menjadi angka nol — Januari 2025 tampil dengan tiga angka 0.00, bukan hilang. Cast ::numeric(14,2) menjaga format dua desimal tetap seragam di semua baris.
  4. sum(...) OVER (ORDER BY b.awal_bulan) menjumlahkan selisih berjalan dari bulan ke bulan — kumulatif arus kas mencapai Rp80.437.000 pada Juni 2026.

Laporan ini layak dibaca dua arah: kolom pengeluaran memperlihatkan bahwa Maret 2026 — bulan Ramadhan dari Bagian 3 — adalah bulan belanja terbesar di paruh pertama 2026 (Rp4.613.000), sementara kolom kumulatif memperlihatkan tren menabung yang stabil naik sepanjang 17 bulan.

Laba-Rugi Sederhana #

Laporan laba-rugi pribadi bisa diringkas menjadi tiga pertanyaan: dari mana uang datang, ke mana ia pergi, dan berapa sisanya. Tiga query laba-rugi.sql menjawab ketiganya:

sql laba-rugi.sql
-- laba-rugi.sql — laporan laba-rugi sederhana untuk seluruh periode
-- Februari 2025 sampai Juni 2026: pemasukan, rincian pengeluaran per
-- kategori utama, lalu ringkasan satu baris.

-- Pemasukan per kategori.
SELECT k.nama AS kategori,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total
FROM kategori k
LEFT JOIN transaksi t ON t.kategori_id = k.id
WHERE k.jenis = 'pemasukan'
GROUP BY k.nama
ORDER BY total DESC;

-- Pengeluaran per kategori utama dengan persentase kontribusi.
-- Window sum() OVER () menghitung total keseluruhan tanpa query terpisah.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total,
       round(sum(t.jumlah) * 100 / sum(sum(t.jumlah)) OVER (), 1) AS persen_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'
GROUP BY COALESCE(p.nama, k.nama)
ORDER BY total DESC;

-- Ringkasan: selisih pemasukan dan pengeluaran dari kolom yang sama.
SELECT sum(jumlah) FILTER (WHERE tipe = 'pemasukan')  AS total_pemasukan,
       sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS total_pengeluaran,
       sum(jumlah) FILTER (WHERE tipe = 'pemasukan')
         - sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS surplus
FROM transaksi;

Output yang Dihasilkan:

text
    kategori    | transaksi |    total     
----------------+-----------+--------------
 gaji           |        17 | 148700000.00
 pemasukan lain |         7 |   4100000.00
(2 rows)

 kategori_utama | transaksi |    total    | persen_total 
----------------+-----------+-------------+--------------
 tagihan        |        51 | 53470000.00 |         73.9
 makan          |        34 | 13109000.00 |         18.1
 hiburan        |        22 |  4120000.00 |          5.7
 transportasi   |        23 |  1109000.00 |          1.5
 kesehatan      |         3 |   555000.00 |          0.8
(5 rows)

 total_pemasukan | total_pengeluaran |   surplus   
-----------------+-------------------+-------------
    152800000.00 |       72363000.00 | 80437000.00
(1 row)

Poin yang layak dicermati:

  1. Pemasukan didominasi gaji (Rp148.700.000 atau 97%) dengan pendapatan sampingan Rp4.100.000 — proporsi yang sehat untuk satu sumber penghasilan utama.
  2. round(sum(t.jumlah) * 100 / sum(sum(t.jumlah)) OVER (), 1) menghitung persentase kontribusi tanpa query terpisah: sum(sum(...)) OVER () adalah window function di atas hasil agregasi — total keseluruhan dihitung tanpa memecah GROUP BY. tagihan menyerap 73,9% pengeluaran, makan 18,1%.
  3. Ringkasan satu baris memakai dua FILTER dari kolom jumlah yang sama: pemasukan Rp152.800.000, pengeluaran Rp72.363.000, surplus Rp80.437.000 — angka yang persis sama dengan kumulatif akhir arus kas. Dua laporan yang dibangun dengan cara berbeda bertemu di angka yang sama; itu bukan kebetulan, itu konsistensi.

Posisi Keuangan per Akun #

Arus kas mengabaikan tempat uang berada; posisi keuangan justru hidup dari situ. Dua query posisi-keuangan.sql menyandingkan per akun:

-- posisi-keuangan.sql — posisi keuangan per akun dengan baris total ROLLUP,
-- lalu evolusi saldo akhir bulanan per akun untuk tiga bulan terakhir.

-- Posisi per akun: saldo awal, arus masuk, arus keluar, saldo akhir.
-- Agregasi per akun diselesaikan lebih dulu di CTE agar saldo_awal tidak
-- tergandakan oleh join ke transaksi; ROLLUP lalu menambah baris TOTAL.
WITH per_akun AS (
    SELECT a.nama, a.saldo_awal,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)  AS masuk,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS keluar
    FROM akun a
    LEFT JOIN transaksi t ON t.akun_id = a.id
    GROUP BY a.id, a.nama, a.saldo_awal
)
SELECT CASE WHEN GROUPING(nama) = 1 THEN 'TOTAL' ELSE nama END AS akun,
       sum(saldo_awal)::numeric(14,2) AS saldo_awal,
       sum(masuk)::numeric(14,2)  AS total_masuk,
       sum(keluar)::numeric(14,2) AS total_keluar,
       (sum(saldo_awal) + sum(masuk) - sum(keluar))::numeric(14,2) AS saldo_akhir
FROM per_akun
GROUP BY ROLLUP (nama)
ORDER BY GROUPING(nama), nama;

-- Saldo akhir bulanan per akun. Kalender bulanan di-cross-join dengan akun
-- agar bulan tanpa mutasi tetap dihitung; window function menjumlahkan netto
-- berjalan sejak Januari 2025, lalu query luar mengambil tiga bulan terakhir.
-- COALESCE per kolom FILTER wajib: NULL - angka menghasilkan NULL.
WITH kalender AS (
    SELECT g.awal_bulan::date AS awal_bulan,
           a.id AS akun_id, a.nama AS akun, a.saldo_awal
    FROM generate_series(DATE '2025-01-01', DATE '2026-06-01', interval '1 month') AS g(awal_bulan)
    CROSS JOIN akun a
),
mutasi AS (
    SELECT t.akun_id,
           date_trunc('month', t.tanggal)::date AS awal_bulan,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)
             - COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS netto
    FROM transaksi t
    GROUP BY 1, 2
),
posisi AS (
    SELECT k.awal_bulan, k.akun,
           COALESCE(m.netto, 0) AS netto,
           k.saldo_awal
             + sum(COALESCE(m.netto, 0))
                 OVER (PARTITION BY k.akun_id ORDER BY k.awal_bulan) AS saldo_akhir_bulan
    FROM kalender k
    LEFT JOIN mutasi m ON m.akun_id = k.akun_id AND m.awal_bulan = k.awal_bulan
)
SELECT to_char(awal_bulan, 'YYYY-MM') AS bulan,
       akun,
       netto::numeric(14,2) AS netto,
       saldo_akhir_bulan::numeric(14,2) AS saldo_akhir
FROM posisi
WHERE awal_bulan >= DATE '2026-04-01'
ORDER BY akun, awal_bulan;

Output yang Dihasilkan:

text
      akun      | saldo_awal | total_masuk  | total_keluar | saldo_akhir 
----------------+------------+--------------+--------------+-------------
 Dompet Digital |  600000.00 |   4100000.00 |   2990000.00 |  1710000.00
 Kas            |  750000.00 |         0.00 |    238000.00 |   512000.00
 Rekening Gaji  | 4200000.00 | 148700000.00 |  69135000.00 | 83765000.00
 TOTAL          | 5550000.00 | 152800000.00 |  72363000.00 | 85987000.00
(4 rows)

  bulan  |      akun      |   netto    | saldo_akhir 
---------+----------------+------------+-------------
 2026-04 | Dompet Digital |  840000.00 |  2000000.00
 2026-05 | Dompet Digital | -156000.00 |  1844000.00
 2026-06 | Dompet Digital | -134000.00 |  1710000.00
 2026-04 | Kas            |  -62000.00 |   538000.00
 2026-05 | Kas            |  -12000.00 |   526000.00
 2026-06 | Kas            |  -14000.00 |   512000.00
 2026-04 | Rekening Gaji  | 4820000.00 | 73225000.00
 2026-05 | Rekening Gaji  | 5270000.00 | 78495000.00
 2026-06 | Rekening Gaji  | 5270000.00 | 83765000.00
(9 rows)

Dua pelajaran penting terkandung di berkas ini:

  1. Agregasi dulu, baru ROLLUP. Query pertama menghitung arus per akun di CTE per_akun, baru ROLLUP menjumlahkannya menjadi baris TOTAL. Kalau ROLLUP dipasang pada join akun × transaksi secara langsung, sum(a.saldo_awal) akan tergandakan sekali per transaksi — saldo awal rekening muncul 132 kali. Fan-out join adalah jebakan klasik laporan keuangan.
  2. COALESCE per kolom FILTER itu wajib. Di CTE mutasi, sum(FILTER pemasukan) - sum(FILTER pengeluaran) tanpa COALESCE menghasilkan NULL untuk bulan yang tidak punya pemasukan — dan NULL dikurangi angka tetap NULL. Akun Kas yang tak pernah menerima pemasukan akan tampak “tidak pernah bergerak”. Kesalahan satu karakter ini membuat laporan berbohong secara diam-diam.

Lihat pula query keduanya: window function berjalan di atas kalender penuh 18 bulan yang di-cross-join dengan tiga akun, baru query luar mengiris tiga bulan terakhir lewat WHERE. Urutannya bukan gaya — kalau WHERE dipindah ke dalam, window hanya melihat potongan dan saldo akhir menjadi salah. Total posisi Rp85.987.000 juga cocok dengan saldo awal Rp5.550.000 ditambah kumulatif arus kas Rp80.437.000: laporan ketiga, angka yang sama.

Tren Kategori Year-over-Year #

Pertanyaan penutup: kategori mana yang tumbuh, dan seberapa cepat? Perbandingan year-over-year menyandingkan Februari-Juni 2025 dengan periode yang sama di 2026:

sql tren-yoy.sql
-- tren-yoy.sql — tren year-over-year kategori: Februari-Juni 2025
-- dibanding Februari-Juni 2026 (periode yang sama pada dua tahun berturut).
-- Januari 2025 kosong sehingga pembanding dimulai Februari.

WITH pengeluaran AS (
    SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
           extract(year FROM t.tanggal)::int AS tahun,
           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 extract(month FROM t.tanggal) BETWEEN 2 AND 6
    GROUP BY COALESCE(p.nama, k.nama), extract(year FROM t.tanggal)::int
)
SELECT kategori_utama,
       max(total) FILTER (WHERE tahun = 2025) AS feb_jun_2025,
       max(total) FILTER (WHERE tahun = 2026) AS feb_jun_2026,
       (max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025)) AS perubahan,
       round((max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025))
             * 100 / max(total) FILTER (WHERE tahun = 2025), 1) AS persen_perubahan
FROM pengeluaran
GROUP BY kategori_utama
ORDER BY persen_perubahan DESC;

WITH pemasukan AS (
    SELECT k.nama AS kategori,
           extract(year FROM t.tanggal)::int AS tahun,
           sum(t.jumlah) AS total
    FROM transaksi t
    JOIN kategori k ON k.id = t.kategori_id
    WHERE t.tipe = 'pemasukan'
      AND extract(month FROM t.tanggal) BETWEEN 2 AND 6
    GROUP BY k.nama, extract(year FROM t.tanggal)::int
)
SELECT kategori,
       max(total) FILTER (WHERE tahun = 2025) AS feb_jun_2025,
       max(total) FILTER (WHERE tahun = 2026) AS feb_jun_2026,
       round((max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025))
             * 100 / max(total) FILTER (WHERE tahun = 2025), 1) AS persen_perubahan
FROM pemasukan
GROUP BY kategori
ORDER BY persen_perubahan DESC;

Output yang Dihasilkan:

text
 kategori_utama | feb_jun_2025 | feb_jun_2026 | perubahan | persen_perubahan 
----------------+--------------+--------------+-----------+------------------
 hiburan        |    980000.00 |   1370000.00 | 390000.00 |             39.8
 kesehatan      |    150000.00 |    185000.00 |  35000.00 |             23.3
 makan          |   3538000.00 |   4210000.00 | 672000.00 |             19.0
 transportasi   |    318000.00 |    336000.00 |  18000.00 |              5.7
 tagihan        |  15750000.00 |  15750000.00 |      0.00 |              0.0
(5 rows)

    kategori    | feb_jun_2025 | feb_jun_2026 | persen_perubahan 
----------------+--------------+--------------+------------------
 pemasukan lain |    400000.00 |   2250000.00 |            462.5
 gaji           |  42500000.00 |  46000000.00 |              8.2
(2 rows)

Pola max(total) FILTER (WHERE tahun = 2025) mungkin terlihat aneh untuk “mengambil nilai satu tahun” — tapi inilah bentuk paling ringkasnya: agregasi FILTER mengambil satu nilai per tahun dalam satu baris hasil, tanpa join tabel ke dirinya sendiri. Ceritanya rapi: hiburan naik 39,8% (Budi makin sering berekreasi), makan naik 19,0%, tagihan dan transportasi nyaris datar karena tagihan tetap dan kebiasaan bensin tidak berubah. Di sisi pemasukan, pemasukan lain melesat 462,5% — dari Rp400.000 menjadi Rp2.250.000 — karena proyek desain lepas yang awalnya coba-coba kini rutin; gaji sendiri naik 8,2% sejak awal 2026.

Database keuangan pribadi Budi kini lengkap: skema yang menegakkan aturannya sendiri, pencatatan harian yang tervalidasi, anggaran yang memburu pelanggaran, dan laporan yang saling cocok angkanya. Setelah proyek ini, material berikutnya membawa pola yang sama ke ranah yang berbeda: database analitik pertanian, tempat deret waktu harga komoditas dan hasil panen bertemu dalam analisis tren dan korelasi.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

arus-kas-bulanan.sql

Unduh berkas
sql
-- arus-kas-bulanan.sql — pemasukan, pengeluaran, selisih, dan kumulatif
-- per bulan. generate_series membuat kalender 18 bulan sehingga bulan tanpa
-- transaksi (Januari 2025) tetap tampil; FILTER memilah arus dalam satu pass.

WITH bulan AS (
    SELECT generate_series(DATE '2025-01-01', DATE '2026-06-01', interval '1 month')::date
           AS awal_bulan
),
arus AS (
    SELECT date_trunc('month', t.tanggal)::date AS awal_bulan,
           sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan')  AS pemasukan,
           sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran') AS pengeluaran
    FROM transaksi t
    GROUP BY 1
)
SELECT to_char(b.awal_bulan, 'YYYY-MM') AS bulan,
       COALESCE(a.pemasukan, 0)::numeric(14,2)   AS pemasukan,
       COALESCE(a.pengeluaran, 0)::numeric(14,2) AS pengeluaran,
       (COALESCE(a.pemasukan, 0) - COALESCE(a.pengeluaran, 0))::numeric(14,2) AS selisih,
       sum(COALESCE(a.pemasukan, 0) - COALESCE(a.pengeluaran, 0))
           OVER (ORDER BY b.awal_bulan)::numeric(14,2) AS kumulatif
FROM bulan b
LEFT JOIN arus a ON a.awal_bulan = b.awal_bulan
ORDER BY b.awal_bulan;

isi-data.sql

Unduh berkas
sql
-- isi-data.sql — riwayat 17 bulan: Februari 2025 (bulan pertama pencatatan)
-- sampai Juni 2026. Januari 2025 sengaja kosong: bulan sebelum Budi mulai
-- mencatat. Transaksi rutin dibangkitkan dengan generate_series; transaksi
-- insidental ditulis satu per satu.

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 kategori (nama, jenis, parent_id) VALUES
    ('gaji',                'pemasukan',  NULL),
    ('pemasukan lain',      'pemasukan',  NULL),
    ('makan',               'pengeluaran', NULL),
    ('makan di luar',       'pengeluaran', 3),
    ('makan di rumah',      'pengeluaran', 3),
    ('transportasi',        'pengeluaran', NULL),
    ('bensin',              'pengeluaran', 6),
    ('parkir dan tol',      'pengeluaran', 6),
    ('tagihan',             'pengeluaran', NULL),
    ('sewa tempat tinggal', 'pengeluaran', 9),
    ('listrik',             'pengeluaran', 9),
    ('internet',            'pengeluaran', 9),
    ('hiburan',             'pengeluaran', NULL),
    ('streaming',           'pengeluaran', 13),
    ('rekreasi',            'pengeluaran', 13),
    ('kesehatan',           'pengeluaran', NULL);

-- Gaji tanggal 25 setiap bulan; naik menjadi 9200000 mulai 2026.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 1, 'pemasukan', g.tanggal,
       'Gaji bulanan ' || to_char(g.tanggal, 'YYYY-MM'),
       CASE WHEN g.tanggal >= DATE '2026-01-01' THEN 9200000 ELSE 8500000 END
FROM generate_series(DATE '2025-02-25', DATE '2026-06-25', interval '1 month') AS g(tanggal);

-- Sewa tempat tinggal tanggal 1 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 10, 'pengeluaran', g.tanggal, 'Sewa tempat tinggal', 2500000
FROM generate_series(DATE '2025-02-01', DATE '2026-06-01', interval '1 month') AS g(tanggal);

-- Internet tanggal 5 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 12, 'pengeluaran', g.tanggal, 'Internet bulanan', 350000
FROM generate_series(DATE '2025-02-05', DATE '2026-06-05', interval '1 month') AS g(tanggal);

-- Belanja dapur tanggal 6 setiap bulan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 5, 'pengeluaran', g.tanggal, 'Belanja dapur bulanan', 650000
FROM generate_series(DATE '2025-02-06', DATE '2026-06-06', interval '1 month') AS g(tanggal);

-- Listrik tanggal 15: lebih mahal pada bulan terik (Maret-Oktober).
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 11, 'pengeluaran', g.tanggal, 'Tagihan listrik',
       CASE WHEN extract(month FROM g.tanggal)::int BETWEEN 3 AND 10
            THEN 310000 ELSE 260000 END
FROM generate_series(DATE '2025-02-15', DATE '2026-06-15', interval '1 month') AS g(tanggal);

-- Langganan streaming tanggal 10, autodebet dari rekening.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 1, 14, 'pengeluaran', g.tanggal, 'Langganan streaming (autodebet)', 120000
FROM generate_series(DATE '2025-02-10', DATE '2026-06-10', interval '1 month') AS g(tanggal);

-- Isi bensin tanggal 8, dibayar QRIS dari dompet digital.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
SELECT 3, 7, 'pengeluaran', g.tanggal, 'Isi bensin (QRIS)', 60000
FROM generate_series(DATE '2025-02-08', DATE '2026-06-08', interval '1 month') AS g(tanggal);

-- Pendapatan sampingan: proyek desain lepas, masuk ke dompet digital.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (3, 2, 'pemasukan', DATE '2025-05-16', 'Proyek desain lepas', 400000.00),
    (3, 2, 'pemasukan', DATE '2025-08-21', 'Proyek desain lepas', 350000.00),
    (3, 2, 'pemasukan', DATE '2025-10-18', 'Proyek desain lepas', 500000.00),
    (3, 2, 'pemasukan', DATE '2025-12-12', 'Proyek desain lepas', 600000.00),
    (3, 2, 'pemasukan', DATE '2026-02-14', 'Proyek desain lepas', 600000.00),
    (3, 2, 'pemasukan', DATE '2026-03-20', 'Proyek desain lepas', 750000.00),
    (3, 2, 'pemasukan', DATE '2026-04-18', 'Proyek desain lepas', 900000.00);

-- Makan di luar: mayoritas QRIS dari dompet digital, sebagian tunai.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (3, 4, 'pengeluaran', DATE '2025-02-14', 'Makan malam akhir pekan',       85000.00),
    (2, 4, 'pengeluaran', DATE '2025-03-21', 'Makan siang keluarga besar',    62000.00),
    (3, 4, 'pengeluaran', DATE '2025-05-10', 'Kopi dan kue',                  45000.00),
    (3, 4, 'pengeluaran', DATE '2025-06-27', 'Brunch akhir pekan',            96000.00),
    (3, 4, 'pengeluaran', DATE '2025-08-15', 'Makan malam perayaan',          78000.00),
    (2, 4, 'pengeluaran', DATE '2025-09-05', 'Makan siang warteg',            25000.00),
    (3, 4, 'pengeluaran', DATE '2025-10-17', 'Kopi sore',                     54000.00),
    (3, 4, 'pengeluaran', DATE '2025-11-08', 'Makan malam ulang tahun',       68000.00),
    (3, 4, 'pengeluaran', DATE '2025-12-20', 'Makan keluarga akhir tahun',   148000.00),
    (3, 4, 'pengeluaran', DATE '2026-01-03', 'Makan malam tahun baru',        88000.00),
    (3, 4, 'pengeluaran', DATE '2026-02-21', 'Makan malam Valentine',        115000.00),
    (3, 4, 'pengeluaran', DATE '2026-03-27', 'Buka puasa keluarga',          148000.00),
    (2, 4, 'pengeluaran', DATE '2026-04-24', 'Makan siang keluarga',          62000.00),
    (3, 4, 'pengeluaran', DATE '2026-05-15', 'Brunch akhir pekan',            96000.00),
    (3, 4, 'pengeluaran', DATE '2026-06-19', 'Makan malam perayaan libur',    74000.00);

-- Parkir dan tol tunai, serta rekreasi dan kesehatan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (2,  8, 'pengeluaran', DATE '2025-04-19', 'Tol akhir pekan',             18000.00),
    (2,  8, 'pengeluaran', DATE '2025-08-22', 'Parkir mal',                  15000.00),
    (2,  8, 'pengeluaran', DATE '2025-12-31', 'Parkir tengah malam',         20000.00),
    (2,  8, 'pengeluaran', DATE '2026-03-28', 'Tol perjalanan',              10000.00),
    (2,  8, 'pengeluaran', DATE '2026-05-02', 'Parkir acara keluarga',       12000.00),
    (2,  8, 'pengeluaran', DATE '2026-06-20', 'Parkir stadion',              14000.00),
    (1, 15, 'pengeluaran', DATE '2025-04-12', 'Liburan akhir pekan',        380000.00),
    (1, 15, 'pengeluaran', DATE '2025-07-19', 'Liburan musim libur',        410000.00),
    (1, 15, 'pengeluaran', DATE '2025-12-28', 'Liburan akhir tahun',        520000.00),
    (3, 15, 'pengeluaran', DATE '2026-02-07', 'Pendakian akhir pekan',      320000.00),
    (1, 15, 'pengeluaran', DATE '2026-04-11', 'Wisata keluarga',            450000.00),
    (3, 16, 'pengeluaran', DATE '2025-05-09', 'Vitamin keluarga',           150000.00),
    (3, 16, 'pengeluaran', DATE '2025-11-21', 'Obat dan konsultasi',        220000.00),
    (3, 16, 'pengeluaran', DATE '2026-02-19', 'Obat flu',                   185000.00);

-- Belanja dapur ekstra di bulan perayaan.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (1, 5, 'pengeluaran', DATE '2025-12-14', 'Belanja bahan Natal',    350000.00),
    (1, 5, 'pengeluaran', DATE '2026-03-21', 'Belanja Ramadhan',       465000.00);

-- Ringkasan muat data: satu baris untuk seluruh register.
SELECT count(*) AS jumlah_transaksi,
       min(tanggal) AS transaksi_pertama,
       max(tanggal) AS transaksi_terakhir,
       sum(jumlah) FILTER (WHERE tipe = 'pemasukan')  AS total_pemasukan,
       sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS total_pengeluaran
FROM transaksi;

-- Sebaran transaksi per kategori utama.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
       k.jenis,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total
FROM kategori k
LEFT JOIN transaksi t ON t.kategori_id = k.id
LEFT JOIN kategori p ON p.id = k.parent_id
GROUP BY COALESCE(p.nama, k.nama), k.jenis
ORDER BY k.jenis, kategori_utama;

laba-rugi.sql

Unduh berkas
sql
-- laba-rugi.sql — laporan laba-rugi sederhana untuk seluruh periode
-- Februari 2025 sampai Juni 2026: pemasukan, rincian pengeluaran per
-- kategori utama, lalu ringkasan satu baris.

-- Pemasukan per kategori.
SELECT k.nama AS kategori,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total
FROM kategori k
LEFT JOIN transaksi t ON t.kategori_id = k.id
WHERE k.jenis = 'pemasukan'
GROUP BY k.nama
ORDER BY total DESC;

-- Pengeluaran per kategori utama dengan persentase kontribusi.
-- Window sum() OVER () menghitung total keseluruhan tanpa query terpisah.
SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
       count(t.id) AS transaksi,
       sum(t.jumlah) AS total,
       round(sum(t.jumlah) * 100 / sum(sum(t.jumlah)) OVER (), 1) AS persen_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'
GROUP BY COALESCE(p.nama, k.nama)
ORDER BY total DESC;

-- Ringkasan: selisih pemasukan dan pengeluaran dari kolom yang sama.
SELECT sum(jumlah) FILTER (WHERE tipe = 'pemasukan')  AS total_pemasukan,
       sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS total_pengeluaran,
       sum(jumlah) FILTER (WHERE tipe = 'pemasukan')
         - sum(jumlah) FILTER (WHERE tipe = 'pengeluaran') AS surplus
FROM transaksi;

posisi-keuangan.sql

Unduh berkas
sql
-- posisi-keuangan.sql — posisi keuangan per akun dengan baris total ROLLUP,
-- lalu evolusi saldo akhir bulanan per akun untuk tiga bulan terakhir.

-- Posisi per akun: saldo awal, arus masuk, arus keluar, saldo akhir.
-- Agregasi per akun diselesaikan lebih dulu di CTE agar saldo_awal tidak
-- tergandakan oleh join ke transaksi; ROLLUP lalu menambah baris TOTAL.
WITH per_akun AS (
    SELECT a.nama, a.saldo_awal,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)  AS masuk,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS keluar
    FROM akun a
    LEFT JOIN transaksi t ON t.akun_id = a.id
    GROUP BY a.id, a.nama, a.saldo_awal
)
SELECT CASE WHEN GROUPING(nama) = 1 THEN 'TOTAL' ELSE nama END AS akun,
       sum(saldo_awal)::numeric(14,2) AS saldo_awal,
       sum(masuk)::numeric(14,2)  AS total_masuk,
       sum(keluar)::numeric(14,2) AS total_keluar,
       (sum(saldo_awal) + sum(masuk) - sum(keluar))::numeric(14,2) AS saldo_akhir
FROM per_akun
GROUP BY ROLLUP (nama)
ORDER BY GROUPING(nama), nama;

-- Saldo akhir bulanan per akun. Kalender bulanan di-cross-join dengan akun
-- agar bulan tanpa mutasi tetap dihitung; window function menjumlahkan netto
-- berjalan sejak Januari 2025, lalu query luar mengambil tiga bulan terakhir.
-- COALESCE per kolom FILTER wajib: NULL - angka menghasilkan NULL.
WITH kalender AS (
    SELECT g.awal_bulan::date AS awal_bulan,
           a.id AS akun_id, a.nama AS akun, a.saldo_awal
    FROM generate_series(DATE '2025-01-01', DATE '2026-06-01', interval '1 month') AS g(awal_bulan)
    CROSS JOIN akun a
),
mutasi AS (
    SELECT t.akun_id,
           date_trunc('month', t.tanggal)::date AS awal_bulan,
           COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)
             - COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS netto
    FROM transaksi t
    GROUP BY 1, 2
),
posisi AS (
    SELECT k.awal_bulan, k.akun,
           COALESCE(m.netto, 0) AS netto,
           k.saldo_awal
             + sum(COALESCE(m.netto, 0))
                 OVER (PARTITION BY k.akun_id ORDER BY k.awal_bulan) AS saldo_akhir_bulan
    FROM kalender k
    LEFT JOIN mutasi m ON m.akun_id = k.akun_id AND m.awal_bulan = k.awal_bulan
)
SELECT to_char(awal_bulan, 'YYYY-MM') AS bulan,
       akun,
       netto::numeric(14,2) AS netto,
       saldo_akhir_bulan::numeric(14,2) AS saldo_akhir
FROM posisi
WHERE awal_bulan >= DATE '2026-04-01'
ORDER BY akun, awal_bulan;

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);

tren-yoy.sql

Unduh berkas
sql
-- tren-yoy.sql — tren year-over-year kategori: Februari-Juni 2025
-- dibanding Februari-Juni 2026 (periode yang sama pada dua tahun berturut).
-- Januari 2025 kosong sehingga pembanding dimulai Februari.

WITH pengeluaran AS (
    SELECT COALESCE(p.nama, k.nama) AS kategori_utama,
           extract(year FROM t.tanggal)::int AS tahun,
           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 extract(month FROM t.tanggal) BETWEEN 2 AND 6
    GROUP BY COALESCE(p.nama, k.nama), extract(year FROM t.tanggal)::int
)
SELECT kategori_utama,
       max(total) FILTER (WHERE tahun = 2025) AS feb_jun_2025,
       max(total) FILTER (WHERE tahun = 2026) AS feb_jun_2026,
       (max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025)) AS perubahan,
       round((max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025))
             * 100 / max(total) FILTER (WHERE tahun = 2025), 1) AS persen_perubahan
FROM pengeluaran
GROUP BY kategori_utama
ORDER BY persen_perubahan DESC;

WITH pemasukan AS (
    SELECT k.nama AS kategori,
           extract(year FROM t.tanggal)::int AS tahun,
           sum(t.jumlah) AS total
    FROM transaksi t
    JOIN kategori k ON k.id = t.kategori_id
    WHERE t.tipe = 'pemasukan'
      AND extract(month FROM t.tanggal) BETWEEN 2 AND 6
    GROUP BY k.nama, extract(year FROM t.tanggal)::int
)
SELECT kategori,
       max(total) FILTER (WHERE tahun = 2025) AS feb_jun_2025,
       max(total) FILTER (WHERE tahun = 2026) AS feb_jun_2026,
       round((max(total) FILTER (WHERE tahun = 2026) - max(total) FILTER (WHERE tahun = 2025))
             * 100 / max(total) FILTER (WHERE tahun = 2025), 1) AS persen_perubahan
FROM pemasukan
GROUP BY kategori
ORDER BY persen_perubahan DESC;