Skema yang dirancang di Bagian 1 ibarat buku tabungan yang belum dibuka: strukturnya siap, tapi belum ada satu baris catatan. Pencatatan harian adalah rutinitas terpanjang dalam hidup database keuangan — jauh melampaui upacara membuat tabelnya — dan di sinilah kualitas skema benar-benar diuji. Pada bagian ini kita mengisi register Februari 2026 milik Budi: tiga akun, sebelas kategori, dua belas transaksi, satu salah ketik yang dikoreksi, lalu saldo dihitung baris demi baris dengan window function. Di akhir bagian, penjaga composite foreign key dibuktikan sekali lagi menolak data yang tidak konsisten.

Menyiapkan Tabel #

Setiap bagian proyek ini mandiri: berkasnya membangun ulang tabel dari nol, sehingga Anda tidak membawa bekas eksekusi bagian sebelumnya. 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

Definisinya identik dengan Bagian 1 — enum yang sama, constraint yang sama, index yang sama — hanya tanpa COMMENT ON supaya fokus tetap pada data. Jika Anda melanjutkan dari bagian sebelumnya pada database yang sama, jalankan berkas ini pada database baru; CREATE TABLE akan menolak membuat tabel duplikat di database yang sudah berisi skema.

Mencatat Transaksi Februari #

Budi mencatat Februari 2026 dengan pola yang khas: sewa dan tagihan jatuh di awal dan pertengahan bulan dari rekening, belanja kecil dari kas, jajan dan brunch dari dompet digital, dan gaji masuk tanggal 25. Satu deskripsi sengaja disisipkan typo lalu dikoreksi — karena di dunia nyata salah ketik pasti terjadi:

-- catat-transaksi.sql — akun, kategori, dan transaksi Februari 2026.
-- Satu kesalahan ketik deskripsi sengaja disisipkan lalu dikoreksi
-- dengan UPDATE ... RETURNING.

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),   -- 1
    ('makan',               'pengeluaran', NULL),  -- 2
    ('makan di luar',       'pengeluaran', 2),     -- 3
    ('makan di rumah',      'pengeluaran', 2),     -- 4
    ('transportasi',        'pengeluaran', NULL),  -- 5
    ('bensin',              'pengeluaran', 5),     -- 6
    ('parkir dan tol',      'pengeluaran', 5),     -- 7
    ('tagihan',             'pengeluaran', NULL),  -- 8
    ('sewa tempat tinggal', 'pengeluaran', 8),     -- 9
    ('listrik',             'pengeluaran', 8),     -- 10
    ('internet',            'pengeluaran', 8);     -- 11

INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (1,  9, 'pengeluaran', DATE '2026-02-01', 'Sewa tempat tinggal Februari', 2500000.00),
    (1, 11, 'pengeluaran', DATE '2026-02-05', 'Internet bulanan',             350000.00),
    (1,  4, 'pengeluaran', DATE '2026-02-06', 'Belnaja dapur mingguan',       350000.00),
    (2,  6, 'pengeluaran', DATE '2026-02-08', 'Isi bensin',                    60000.00),
    (2,  3, 'pengeluaran', DATE '2026-02-10', 'Makan siang warteg',            25000.00),
    (2,  7, 'pengeluaran', DATE '2026-02-14', 'Parkir kantor',                 12000.00),
    (1, 10, 'pengeluaran', DATE '2026-02-15', 'Listrik Februari',              260000.00),
    (3,  3, 'pengeluaran', DATE '2026-02-17', 'Kopi dengan teman',             78000.00),
    (2,  6, 'pengeluaran', DATE '2026-02-19', 'Isi bensin',                    60000.00),
    (1,  4, 'pengeluaran', DATE '2026-02-20', 'Belanja dapur kedua',           300000.00),
    (3,  3, 'pengeluaran', DATE '2026-02-21', 'Brunch akhir pekan',            96000.00),
    (1,  1, 'pemasukan',  DATE '2026-02-25', 'Gaji bulanan Februari',        9200000.00);

-- Koreksi salah ketik pada deskripsi belanja dapur.
UPDATE transaksi
SET deskripsi = 'Belanja dapur mingguan'
WHERE deskripsi = 'Belnaja dapur mingguan'
RETURNING id, tanggal, deskripsi;

-- Register Februari: urutan pencatatan lengkap.
SELECT t.tanggal, a.nama AS akun, k.nama AS kategori, t.tipe, t.deskripsi, t.jumlah
FROM transaksi t
JOIN akun a ON a.id = t.akun_id
JOIN kategori k ON k.id = t.kategori_id
ORDER BY t.tanggal, t.id;

Output yang Dihasilkan:

text
INSERT 0 3
INSERT 0 11
INSERT 0 12
 id |  tanggal   |       deskripsi        
----+------------+------------------------
  3 | 2026-02-06 | Belanja dapur mingguan
(1 row)

UPDATE 1
  tanggal   |      akun      |      kategori       |    tipe     |          deskripsi           |   jumlah   
------------+----------------+---------------------+-------------+------------------------------+------------
 2026-02-01 | Rekening Gaji  | sewa tempat tinggal | pengeluaran | Sewa tempat tinggal Februari | 2500000.00
 2026-02-05 | Rekening Gaji  | internet            | pengeluaran | Internet bulanan             |  350000.00
 2026-02-06 | Rekening Gaji  | makan di rumah      | pengeluaran | Belanja dapur mingguan       |  350000.00
 2026-02-08 | Kas            | bensin              | pengeluaran | Isi bensin                   |   60000.00
 2026-02-10 | Kas            | makan di luar       | pengeluaran | Makan siang warteg           |   25000.00
 2026-02-14 | Kas            | parkir dan tol      | pengeluaran | Parkir kantor                |   12000.00
 2026-02-15 | Rekening Gaji  | listrik             | pengeluaran | Listrik Februari             |  260000.00
 2026-02-17 | Dompet Digital | makan di luar       | pengeluaran | Kopi dengan teman            |   78000.00
 2026-02-19 | Kas            | bensin              | pengeluaran | Isi bensin                   |   60000.00
 2026-02-20 | Rekening Gaji  | makan di rumah      | pengeluaran | Belanja dapur kedua          |  300000.00
 2026-02-21 | Dompet Digital | makan di luar       | pengeluaran | Brunch akhir pekan           |   96000.00
 2026-02-25 | Rekening Gaji  | gaji                | pemasukan   | Gaji bulanan Februari        | 9200000.00
(12 rows)

Perhatikan tiga hal pada alur ini:

  1. Urutan INSERT mengikuti urutan tanggal, bukan kebetulan: register dibaca kronologis, dan ORDER BY t.tanggal, t.id pada query penutup memakai id sebagai tie-break untuk transaksi sehari — urutan hasil selalu pasti.
  2. Koreksi lewat UPDATE ... RETURNING: bukti baris mana yang berubah tampil tanpa query verifikasi tambahan. Filter koreksi memakai nilai kolom yang unik (deskripsi salah ketik itu sendiri), bukan rentang tanggal yang bisa menyapu baris lain.
  3. Dua belas baris, dua akun utama dan dompet digital bergantian: kolom tipe terlihat seragam pengeluaran kecuali gaji — dan itulah intinya: satu-satunya cara memasukkan pemasukan adalah dengan tipe = 'pemasukan' pada kategori gaji yang memang berjenis pemasukan.

Saldo Berjalan dengan Window Function #

Pertanyaan paling sering dalam pencatatan uang: “berapa saldo aku sekarang?” — atau lebih tajam: “berapa saldo aku setelah transaksi ini?” Kolom saldo tidak disimpan; ia dihitung dari saldo awal plus jumlah berjalan. Berikut saldo-berjalan.sql:

-- saldo-berjalan.sql — saldo akun baris demi baris dengan window function,
-- lalu posisi akhir semua akun dalam satu query dengan FILTER.

-- Buku mutasi Rekening Gaji: saldo awal + jumlah berjalan.
-- Pemasukan dihitung positif, pengeluaran negatif, lalu dijumlahkan
-- berurutan dengan SUM(...) OVER (ORDER BY tanggal, id).
SELECT t.tanggal,
       t.deskripsi,
       t.tipe,
       t.jumlah,
       a.saldo_awal
         + sum(CASE WHEN t.tipe = 'pemasukan' THEN t.jumlah
                    ELSE -t.jumlah END)
               OVER (ORDER BY t.tanggal, t.id) AS saldo_berjalan
FROM transaksi t
JOIN akun a ON a.id = t.akun_id
WHERE a.nama = 'Rekening Gaji'
ORDER BY t.tanggal, t.id;

-- Posisi akhir semua akun: agregasi FILTER memisahkan arus masuk dan keluar.
SELECT a.nama,
       a.saldo_awal,
       sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan')  AS total_masuk,
       sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran') AS total_keluar,
       a.saldo_awal
         + COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)
         - COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS saldo_akhir
FROM akun a
LEFT JOIN transaksi t ON t.akun_id = a.id
GROUP BY a.id, a.nama, a.saldo_awal
ORDER BY a.id;

Output yang Dihasilkan:

text
  tanggal   |          deskripsi           |    tipe     |   jumlah   | saldo_berjalan 
------------+------------------------------+-------------+------------+----------------
 2026-02-01 | Sewa tempat tinggal Februari | pengeluaran | 2500000.00 |     1700000.00
 2026-02-05 | Internet bulanan             | pengeluaran |  350000.00 |     1350000.00
 2026-02-06 | Belanja dapur mingguan       | pengeluaran |  350000.00 |     1000000.00
 2026-02-15 | Listrik Februari             | pengeluaran |  260000.00 |      740000.00
 2026-02-20 | Belanja dapur kedua          | pengeluaran |  300000.00 |      440000.00
 2026-02-25 | Gaji bulanan Februari        | pemasukan   | 9200000.00 |     9640000.00
(6 rows)

      nama      | saldo_awal | total_masuk | total_keluar | saldo_akhir 
----------------+------------+-------------+--------------+-------------
 Rekening Gaji  | 4200000.00 |  9200000.00 |   3760000.00 |  9640000.00
 Kas            |  750000.00 |             |    157000.00 |   593000.00
 Dompet Digital |  600000.00 |             |    174000.00 |   426000.00
(3 rows)

Cara kerjanya per bagian:

  1. CASE WHEN t.tipe = 'pemasukan' THEN t.jumlah ELSE -t.jumlah END mengubah arah arus menjadi bertanda — pemasukan positif, pengeluaran negatif — hanya di dalam query, tanpa pernah menyimpan jumlah negatif di tabel. Ini pemersatu dua dunia: penyimpanan aman (jumlah selalu positif), laporan fleksibel (nilai bertanda dihitung saat dibutuhkan).
  2. sum(...) OVER (ORDER BY t.tanggal, t.id) menjumlahkan bertingkat: untuk setiap baris, window function menjumlahkan semua baris sampai baris itu — inilah running total. ORDER BY ganda (tanggal, lalu id) memastikan dua transaksi di hari yang sama dijumlahkan dalam urutan pencatatan.
  3. a.saldo_awal + di depan window sum menambatkan titik hitung: saldo berjalan Februari dimulai dari Rp4.200.000, bukan dari nol. Karena WHERE a.nama = 'Rekening Gaji' memfilter ke satu akun, PARTITION BY tidak diperlukan — semua baris memang satu partisi.
  4. Query kedua memakai FILTER untuk memisahkan arus: sum(jumlah) FILTER (WHERE tipe = 'pemasukan') menjumlahkan hanya pemasukan dalam satu pass tanpa CASE. Baris Kas dan Dompet Digital menampilkan kolom total_masuk kosong — itu NULL, artinya memang tidak ada pemasukan; COALESCE hanya dipakai di perhitungan saldo_akhir agar NULL tidak menular mengubah hasil jadi NULL.

Hasilnya membaca seperti cerita: saldo rekening menyusut dari Rp4.200.000 sampai Rp440.000 menjelang gajian — sewa, internet, belanja, listrik, streaming — lalu melompat ke Rp9.640.000 ketika gaji Februarian masuk. Tanggal gajian yang hampir akhir bulan bukan kebetulan; ia adalah alasan saldo berjalan penting dipantau, bukan sekadar saldo akhir bulan.

Tipe yang Tidak Cocok #

Penjaga terakhir dibuktikan dengan berkas yang memang gagal: seorang pengguna mengantongi uang “bonus” dan mencoba mencatatnya sebagai pemasukan — padahal kategori yang dipilihnya adalah bensin, kategori pengeluaran:

-- tipe-tak-cocok.sql — tipe transaksi yang tidak cocok dengan kategorinya
-- harus ditolak composite foreign key.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 6, 'pemasukan', DATE '2026-03-01', 'Bonus fiktif', 500000.00);

Output yang Dihasilkan:

text
psql:tipe-tak-cocok.sql:5: ERROR:  insert or update on table "transaksi" violates foreign key constraint "tipe_cocok_dengan_kategori"
DETAIL:  Key (kategori_id, tipe)=(6, pemasukan) is not present in table "kategori".

Pesan errornya menyebut constraint tipe_cocok_dengan_kategori lengkap dengan pasangan kunci yang gagal: (kategori_id, tipe) = (6, pemasukan). Desain “kolom tipe + jumlah positif” yang dipilih di Bagian 1 terbayar di sini: tanpa kolom tipe, database tidak punya apa pun yang bisa diperiksa terhadap kategori, dan kesalahan semacam ini hanya akan ketahuan bulan kemudian di laporan yang aneh.

Register Februari kini hidup dan saldonya bisa dibaca kapan pun. Tapi dua belas transaksi dan sebelas kategori belum menunjukkan kekuatan sebenarnya. Bagian 3 melengkapi pohon kategori hingga dua tingkat penuh, menggulung pengeluaran dari subkategori ke kategori utama, dan memasang anggaran bulanan yang tahu kapan ia dilanggar.

Referensi #

Berkas Sumber

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

catat-transaksi.sql

Unduh berkas
sql
-- catat-transaksi.sql — akun, kategori, dan transaksi Februari 2026.
-- Satu kesalahan ketik deskripsi sengaja disisipkan lalu dikoreksi
-- dengan UPDATE ... RETURNING.

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),   -- 1
    ('makan',               'pengeluaran', NULL),  -- 2
    ('makan di luar',       'pengeluaran', 2),     -- 3
    ('makan di rumah',      'pengeluaran', 2),     -- 4
    ('transportasi',        'pengeluaran', NULL),  -- 5
    ('bensin',              'pengeluaran', 5),     -- 6
    ('parkir dan tol',      'pengeluaran', 5),     -- 7
    ('tagihan',             'pengeluaran', NULL),  -- 8
    ('sewa tempat tinggal', 'pengeluaran', 8),     -- 9
    ('listrik',             'pengeluaran', 8),     -- 10
    ('internet',            'pengeluaran', 8);     -- 11

INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah) VALUES
    (1,  9, 'pengeluaran', DATE '2026-02-01', 'Sewa tempat tinggal Februari', 2500000.00),
    (1, 11, 'pengeluaran', DATE '2026-02-05', 'Internet bulanan',             350000.00),
    (1,  4, 'pengeluaran', DATE '2026-02-06', 'Belnaja dapur mingguan',       350000.00),
    (2,  6, 'pengeluaran', DATE '2026-02-08', 'Isi bensin',                    60000.00),
    (2,  3, 'pengeluaran', DATE '2026-02-10', 'Makan siang warteg',            25000.00),
    (2,  7, 'pengeluaran', DATE '2026-02-14', 'Parkir kantor',                 12000.00),
    (1, 10, 'pengeluaran', DATE '2026-02-15', 'Listrik Februari',              260000.00),
    (3,  3, 'pengeluaran', DATE '2026-02-17', 'Kopi dengan teman',             78000.00),
    (2,  6, 'pengeluaran', DATE '2026-02-19', 'Isi bensin',                    60000.00),
    (1,  4, 'pengeluaran', DATE '2026-02-20', 'Belanja dapur kedua',           300000.00),
    (3,  3, 'pengeluaran', DATE '2026-02-21', 'Brunch akhir pekan',            96000.00),
    (1,  1, 'pemasukan',  DATE '2026-02-25', 'Gaji bulanan Februari',        9200000.00);

-- Koreksi salah ketik pada deskripsi belanja dapur.
UPDATE transaksi
SET deskripsi = 'Belanja dapur mingguan'
WHERE deskripsi = 'Belnaja dapur mingguan'
RETURNING id, tanggal, deskripsi;

-- Register Februari: urutan pencatatan lengkap.
SELECT t.tanggal, a.nama AS akun, k.nama AS kategori, t.tipe, t.deskripsi, t.jumlah
FROM transaksi t
JOIN akun a ON a.id = t.akun_id
JOIN kategori k ON k.id = t.kategori_id
ORDER BY t.tanggal, t.id;

saldo-berjalan.sql

Unduh berkas
sql
-- saldo-berjalan.sql — saldo akun baris demi baris dengan window function,
-- lalu posisi akhir semua akun dalam satu query dengan FILTER.

-- Buku mutasi Rekening Gaji: saldo awal + jumlah berjalan.
-- Pemasukan dihitung positif, pengeluaran negatif, lalu dijumlahkan
-- berurutan dengan SUM(...) OVER (ORDER BY tanggal, id).
SELECT t.tanggal,
       t.deskripsi,
       t.tipe,
       t.jumlah,
       a.saldo_awal
         + sum(CASE WHEN t.tipe = 'pemasukan' THEN t.jumlah
                    ELSE -t.jumlah END)
               OVER (ORDER BY t.tanggal, t.id) AS saldo_berjalan
FROM transaksi t
JOIN akun a ON a.id = t.akun_id
WHERE a.nama = 'Rekening Gaji'
ORDER BY t.tanggal, t.id;

-- Posisi akhir semua akun: agregasi FILTER memisahkan arus masuk dan keluar.
SELECT a.nama,
       a.saldo_awal,
       sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan')  AS total_masuk,
       sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran') AS total_keluar,
       a.saldo_awal
         + COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pemasukan'), 0)
         - COALESCE(sum(t.jumlah) FILTER (WHERE t.tipe = 'pengeluaran'), 0) AS saldo_akhir
FROM akun a
LEFT JOIN transaksi t ON t.akun_id = a.id
GROUP BY a.id, a.nama, a.saldo_awal
ORDER BY a.id;

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

tipe-tak-cocok.sql

Unduh berkas
sql
-- tipe-tak-cocok.sql — tipe transaksi yang tidak cocok dengan kategorinya
-- harus ditolak composite foreign key.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 6, 'pemasukan', DATE '2026-03-01', 'Bonus fiktif', 500000.00);