Aplikasi pencatatan uang gagal bukan karena query-nya lambat, melainkan karena datanya berbohong: saldo minus yang tidak pernah terjadi, pengeluaran bernilai negatif hasil salah ketik, pemasukan yang tercatat di kategori pengeluaran. Semua kebohongan itu paling murah dicegah di skema — sekali dipasang sebagai constraint, aturannya berlaku untuk semua aplikasi yang menyentuh data. Pada bagian ini kita menerjemahkan kebutuhan Budi menjadi empat tabel, memutuskan bagaimana uang dan arah arus kas direpresentasikan, mengikat konsistensi kategori dengan composite foreign key, lalu membuktikan lewat uji bahwa setiap aturan benar-benar ditegakkan PostgreSQL.
Dari Cerita Kebutuhan ke Entitas #
Budi bercerita singkat: aku punya tiga tempat uang — rekening gaji, kas tunai, dan dompet digital — masing-masing dengan saldo sekarang. Tiap hari ada uang masuk dan keluar; tiap transaksi terjadi di satu akun, pada satu tanggal, untuk satu kategori. Kategoriku bertingkat: makan terbagi menjadi makan di luar dan makan di rumah. Tiap awal bulan aku menetapkan batas pengeluaran per kategori, dan aku ingin tahu kapan batas itu jebol.
Dari cerita itu entitasnya menyingkir sendiri:
akun— tempat uang berada, dengan saldo awal sebagai titik hitung.kategori— penggolongan transaksi, hierarkis lewat kolomparent_idyang merujuk ke baris kategori lain.transaksi— peristiwa tunggal arus kas: satu akun, satu kategori, satu tanggal, satu jumlah.anggaran— batas pengeluaran per kategori per bulan.
Satu keputusan penting diambil sejak awal: saldo akhir tidak pernah disimpan sebagai kolom. Saldo adalah hasil hitung — saldo awal ditambah seluruh transaksi — dan akan dihitung dengan window function di Bagian 2. Menyimpan angka turunan berarti menjaga dua sumber kebenaran yang bisa saling bertengkar.
Uang Selalu numeric #
Semua kolom bernilai uang memakai numeric(14,2): presisi penuh dengan dua angka desimal. Tipe float/double precision memakai representasi biner yang tidak bisa menyimpan 0,10 secara persis, sehingga menjumlahkan ribuan transaksi menghasilkan selisih beberapa rupiah — kejadian yang wajar di komputasi ilmiah tapi memalukan di pembukuan. numeric menghitung dalam desimal exact, dan numeric(14,2) masih menampung belasan triliun rupiah dengan dua desimal — cukup untuk keuangan pribadi siapa pun.
Tabel Akun dan Kategori Hierarkis #
Dua tabel pertama memuat master data. Berikut tabel-dasar.sql:
-- tabel-dasar.sql — tipe enum arus kas, tabel akun, dan kategori hierarkis.
-- Kolom jenis dipakai bersama oleh kategori dan transaksi sehingga
-- kecocokan keduanya nanti bisa diikat composite foreign key.
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)
);
COMMENT ON TABLE akun IS 'Tempat uang berada: kas, rekening bank, atau dompet digital.';
COMMENT ON COLUMN akun.saldo_awal IS 'Saldo saat pencatatan pertama kali dimulai; tidak pernah di-update.';
COMMENT ON TABLE kategori IS 'Kategori transaksi; anak mewarisi jenis pemasukan/pengeluaran dari induk.';
COMMENT ON COLUMN kategori.parent_id IS 'NULL berarti kategori tingkat atas (induk).';Output yang Dihasilkan:
CREATE TYPE
CREATE TABLE
CREATE TABLE
COMMENT
COMMENT
COMMENT
COMMENTBedah keputusan desainnya:
jenis IN ('kas', 'rekening', 'dompet digital'): daftar tertutup yang dijagaCHECK. Menambah jenis akun baru berarti mengubah constraint — sengaja dibuat sedikit mahal supaya pertumbuhan struktur data adalah keputusan, bukan kecelakaan ketik.saldo_awal >= 0dan tipenumeric(14,2): saldo awal negatif tidak masuk akal untuk proyek ini, dan tipe data menjaga presisi uang.jenis tipe_arus_kas NOT NULL: kolom ini memakai enum yang dideklarasikan di awal berkas. Enum menyimpan labelpemasukan/pengeluaransebagai katalog internal yang padat — nilainya punya urutan pasti dan tidak bisa dieja salah.FOREIGN KEY (parent_id, jenis) REFERENCES kategori (id, jenis): ini jantung hierarki. Foreign key komposit ini memaksa pasangan induk-anak berjenis sama — subkategoribonusberjenispemasukantidak akan pernah bisa menempel di indukmakanyang berjenispengeluaran.
Tabel Transaksi dan Anggaran #
Sebelum menulis DDL transaksi, satu keputusan desain perlu diambil secara sadar: bagaimana membedakan pemasukan dari pengeluaran? Ada dua sekolah:
| Aspek | Jumlah bertanda (+/-) | Kolom tipe + jumlah positif |
|---|---|---|
| Penyimpanan | satu kolom jumlah bisa negatif | kolom tipe + jumlah selalu > 0 |
| Kesalahan ketik | -50000 sah, sulit dideteksi | jumlah <= 0 langsung ditolak CHECK |
| Laporan | sum() langsung bersih | butuh FILTER atau CASE saat agregasi |
| Kategori | tipe kategori tidak dipakai | tipe harus cocok dengan kategori |
Kita memilih kolom tipe + jumlah positif. Alasannya menimbang: salah ketik tanda minus adalah bug pembukuan klasik yang bisa lolos bertahun-tahun, sedangkan biaya FILTER saat membuat laporan hanya dibayar sekali di query. Bonusnya, kolom tipe bisa diikat ke kategori.jenis lewat composite foreign key — penjaga yang tidak dimiliki desain jumlah bertanda. Berikut tabel-transaksi.sql:
-- tabel-transaksi.sql — tabel transaksi dan anggaran bulanan.
-- Keputusan desain: kolom tipe + jumlah selalu positif; kecocokan tipe
-- dengan kategori diikat composite foreign key (kategori_id, tipe).
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)
);
COMMENT ON TABLE transaksi IS 'Satu baris = satu pencatatan uang masuk atau uang keluar.';
COMMENT ON COLUMN transaksi.jumlah IS 'Selalu positif; arah arus kas ditentukan kolom tipe.';
COMMENT ON TABLE anggaran IS 'Batas pengeluaran per kategori per bulan; periode selalu tanggal 1.';Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
COMMENT
COMMENT
COMMENTTiga hal yang layak digarisbawahi:
jumlah_harus_positif: nama constraint eksplisit membuat pesan error menyebut aturan yang dilanggar, bukan nama default sepertitransaksi_jumlah_checkyang harus Anda tebak-tebak.FOREIGN KEY (kategori_id, tipe) REFERENCES kategori (id, jenis): transaksi dengantipe = 'pemasukan'hanya sah bila pasangan(kategori_id, 'pemasukan')ada di kategori — artinya kategori itu memang berjenis pemasukan. Konsistensi ini ditegakkan di tingkat data, bukan di form aplikasi.periode = date_trunc('month', periode)::date: anggaran disimpan per bulan danperiodedipaku menjadi tanggal 1.date_trunc('month', ...)memotong tanggal ke awal bulan; jika yang disimpan adalah 15 Maret, hasil pemotongan (1 Maret) tidak sama dengan inputnya dan constraint menolak.UNIQUE (kategori_id, periode)lalu memastikan satu kategori hanya punya satu anggaran per bulan.
Index untuk Pola Akses Laporan #
PRIMARY KEY, UNIQUE, dan constraint unik komposit otomatis membuat index. Kolom foreign key tidak — padahal transaksi.tanggal adalah kolom yang paling sering difilter saat laporan bulanan, dan (akun_id, tanggal) adalah pola akses buku mutasi. Kita pasang tiga index secara manual:
-- indeks-transaksi.sql — index untuk pola akses laporan keuangan.
-- PRIMARY KEY dan UNIQUE otomatis membuat index; kolom foreign key dan
-- kolom tanggal pada transaksi TIDAK, padahal paling sering difilter
-- dan di-join, sehingga index berikut dipasang manual.
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);
-- Bukti: daftar index pada tabel transaksi dan anggaran.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename IN ('transaksi', 'anggaran')
ORDER BY tablename, indexname;Output yang Dihasilkan:
CREATE INDEX
CREATE INDEX
CREATE INDEX
indexname | indexdef
----------------------------+-----------------------------------------------------------------------------------------------------
anggaran_pkey | CREATE UNIQUE INDEX anggaran_pkey ON public.anggaran USING btree (id)
satu_anggaran_per_periode | CREATE UNIQUE INDEX satu_anggaran_per_periode ON public.anggaran USING btree (kategori_id, periode)
idx_transaksi_akun_tanggal | CREATE INDEX idx_transaksi_akun_tanggal ON public.transaksi USING btree (akun_id, tanggal)
idx_transaksi_kategori | CREATE INDEX idx_transaksi_kategori ON public.transaksi USING btree (kategori_id)
idx_transaksi_tanggal | CREATE INDEX idx_transaksi_tanggal ON public.transaksi USING btree (tanggal)
transaksi_pkey | CREATE UNIQUE INDEX transaksi_pkey ON public.transaksi USING btree (id)
(6 rows)Query penutupnya membaca view pg_indexes dari system catalog. Enam index tampil: tiga dibuat otomatis oleh PRIMARY KEY dan UNIQUE (transaksi_pkey, anggaran_pkey, satu_anggaran_per_periode), dan tiga berprefiks idx_ dipasang manual. Index komposit (akun_id, tanggal) sengaja dibuat dua kolom karena query buku mutasi selalu menyaring keduanya sekaligus: satu akun, rentang tanggal.
Menguji Skema: Positif dan Negatif #
Skema belum bisa dipercaya sebelum aturannya terbukti. Berkas berikut menguji sembilan kasus dalam satu eksekusi — satu uji positif dan delapan uji negatif, masing-masing dibungkus BEGIN ... EXCEPTION di dalam DO block agar psql tidak berhenti di error pertama:
-- uji-constraint.sql — uji positif dan uji negatif seluruh constraint skema.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu file
-- bisa menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.
INSERT INTO akun (nama, jenis, saldo_awal)
VALUES ('Kas', 'kas', 500000.00);
INSERT INTO kategori (nama, jenis) VALUES ('gaji', 'pemasukan');
INSERT INTO kategori (nama, jenis) VALUES ('makan', 'pengeluaran');
INSERT INTO kategori (nama, jenis, parent_id)
VALUES ('makan di luar', 'pengeluaran', 2);
-- Baris sah sebagai pembanding: transaksi pengeluaran bernilai positif.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-05', 'Makan siang', 25000.00);
-- Anggaran sah: periode awal bulan dan batas positif.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-01', 400000.00);
CREATE TABLE hasil_uji (
urutan integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kasus text NOT NULL,
hasil text NOT NULL
);
DO $$
BEGIN
-- Uji positif: data sah kedua harus diterima.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-06', 'Kopi sore', 18000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('transaksi sah kedua diterima', 'LULUS: baris tersimpan');
-- Uji negatif: tiap pelanggaran harus ditolak database.
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-07', 'Salah ketik', -50000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi negatif', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi negatif', 'LULUS: ditolak CHECK jumlah_harus_positif');
END;
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-07', 'Pengembalian nol', 0.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi nol', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi nol', 'LULUS: ditolak CHECK jumlah_harus_positif');
END;
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pemasukan', DATE '2026-01-08', 'Salah pilih tipe', 50000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('tipe pemasukan di kategori pengeluaran', 'GAGAL: baris justru diterima');
EXCEPTION WHEN foreign_key_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('tipe pemasukan di kategori pengeluaran', 'LULUS: ditolak FK tipe_cocok_dengan_kategori');
END;
BEGIN
INSERT INTO kategori (nama, jenis, parent_id)
VALUES ('bonus', 'pemasukan', 3);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('subkategori beda jenis dengan induk', 'GAGAL: baris justru diterima');
EXCEPTION WHEN foreign_key_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('subkategori beda jenis dengan induk', 'LULUS: ditolak FK induk_sama_jenis');
END;
BEGIN
INSERT INTO akun (nama, jenis, saldo_awal)
VALUES ('Kantong Ajaib', 'brankas', 100000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jenis akun tidak dikenal', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jenis akun tidak dikenal', 'LULUS: ditolak CHECK jenis akun');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-15', 400000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('periode anggaran tanggal 15', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('periode anggaran tanggal 15', 'LULUS: ditolak CHECK periode_awal_bulan');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-04-01', 0.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('batas anggaran nol', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('batas anggaran nol', 'LULUS: ditolak CHECK batas_harus_positif');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-01', 500000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('anggaran ganda untuk kategori dan bulan sama', 'GAGAL: baris justru diterima');
EXCEPTION WHEN unique_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('anggaran ganda untuk kategori dan bulan sama', 'LULUS: ditolak UNIQUE satu_anggaran_per_periode');
END;
END $$;
SELECT urutan, kasus, hasil
FROM hasil_uji
ORDER BY urutan;Output yang Dihasilkan:
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
INSERT 0 1
CREATE TABLE
DO
urutan | kasus | hasil
--------+----------------------------------------------+-------------------------------------------------
1 | transaksi sah kedua diterima | LULUS: baris tersimpan
2 | jumlah transaksi negatif | LULUS: ditolak CHECK jumlah_harus_positif
3 | jumlah transaksi nol | LULUS: ditolak CHECK jumlah_harus_positif
4 | tipe pemasukan di kategori pengeluaran | LULUS: ditolak FK tipe_cocok_dengan_kategori
5 | subkategori beda jenis dengan induk | LULUS: ditolak FK induk_sama_jenis
6 | jenis akun tidak dikenal | LULUS: ditolak CHECK jenis akun
7 | periode anggaran tanggal 15 | LULUS: ditolak CHECK periode_awal_bulan
8 | batas anggaran nol | LULUS: ditolak CHECK batas_harus_positif
9 | anggaran ganda untuk kategori dan bulan sama | LULUS: ditolak UNIQUE satu_anggaran_per_periode
(9 rows)Sembilan baris LULUS berarti sembilan aturan bekerja: transaksi sah tetap diterima, jumlah negatif dan nol ditolak, tipe yang tidak cocok kategori ditolak composite foreign key, subkategori beda jenis dengan induknya ditolak, jenis akun asing ditolak, periode anggaran harus awal bulan, batas anggaran harus positif, dan anggaran ganda untuk kategori-bulan yang sama ditolak UNIQUE. Pola LULUS/GAGAL ini akan dipakai terus di seluruh proyek, dan versi lengkapnya tersedia di folder tests/ pada Berkas Sumber halaman overview.
Jumlah Negatif Ditolak #
Uji terakhir sengaja dibuat sebagai berkas terpisah yang memang gagal — supaya pesan error aslinya terlihat, bukan hanya dirangkum. Koreksi salah ketik bernilai minus harus ditolak:
-- jumlah-negatif.sql — jumlah transaksi negatif harus ditolak CHECK.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-09', 'Koreksi salah ketik', -50000.00);Output yang Dihasilkan:
psql:jumlah-negatif.sql:4: ERROR: new row for relation "transaksi" violates check constraint "jumlah_harus_positif"
DETAIL: Failing row contains (6, 1, 3, pengeluaran, 2026-01-09, Koreksi salah ketik, -50000.00).Dengan ON_ERROR_STOP=1, pelanggaran CHECK menghentikan berkas dan membuat psql keluar dengan kode 3 — kode khusus “error SQL”. Pada pipeline pengujian, exit 3 pada berkas yang memang menguji penolakan berarti lulus. Pesan errornya menyebut jumlah_harus_positif lengkap dengan baris yang gagal — keuntungan memberi nama eksplisit pada constraint sejak awal.
Skema kini berdiri dan teruji, tapi masih kosong penghuni: belum ada satu transaksi pun tercatat. Bagian 2 menghidupkannya — mencatat pemasukan dan pengeluaran harian Budi, mengoreksi salah ketik, dan menghitung saldo berjalan per akun dengan window function.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
indeks-transaksi.sql
Unduh berkas-- indeks-transaksi.sql — index untuk pola akses laporan keuangan.
-- PRIMARY KEY dan UNIQUE otomatis membuat index; kolom foreign key dan
-- kolom tanggal pada transaksi TIDAK, padahal paling sering difilter
-- dan di-join, sehingga index berikut dipasang manual.
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);
-- Bukti: daftar index pada tabel transaksi dan anggaran.
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename IN ('transaksi', 'anggaran')
ORDER BY tablename, indexname;
jumlah-negatif.sql
Unduh berkas-- jumlah-negatif.sql — jumlah transaksi negatif harus ditolak CHECK.
-- File ini sengaja berakhir dengan error; psql keluar dengan kode 3.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-09', 'Koreksi salah ketik', -50000.00);
tabel-dasar.sql
Unduh berkas-- tabel-dasar.sql — tipe enum arus kas, tabel akun, dan kategori hierarkis.
-- Kolom jenis dipakai bersama oleh kategori dan transaksi sehingga
-- kecocokan keduanya nanti bisa diikat composite foreign key.
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)
);
COMMENT ON TABLE akun IS 'Tempat uang berada: kas, rekening bank, atau dompet digital.';
COMMENT ON COLUMN akun.saldo_awal IS 'Saldo saat pencatatan pertama kali dimulai; tidak pernah di-update.';
COMMENT ON TABLE kategori IS 'Kategori transaksi; anak mewarisi jenis pemasukan/pengeluaran dari induk.';
COMMENT ON COLUMN kategori.parent_id IS 'NULL berarti kategori tingkat atas (induk).';
tabel-transaksi.sql
Unduh berkas-- tabel-transaksi.sql — tabel transaksi dan anggaran bulanan.
-- Keputusan desain: kolom tipe + jumlah selalu positif; kecocokan tipe
-- dengan kategori diikat composite foreign key (kategori_id, tipe).
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)
);
COMMENT ON TABLE transaksi IS 'Satu baris = satu pencatatan uang masuk atau uang keluar.';
COMMENT ON COLUMN transaksi.jumlah IS 'Selalu positif; arah arus kas ditentukan kolom tipe.';
COMMENT ON TABLE anggaran IS 'Batas pengeluaran per kategori per bulan; periode selalu tanggal 1.';
uji-constraint.sql
Unduh berkas-- uji-constraint.sql — uji positif dan uji negatif seluruh constraint skema.
-- Pelanggaran dibungkus BEGIN...EXCEPTION di dalam DO block sehingga satu file
-- bisa menguji banyak kasus; hasilnya dirangkum LULUS/GAGAL.
INSERT INTO akun (nama, jenis, saldo_awal)
VALUES ('Kas', 'kas', 500000.00);
INSERT INTO kategori (nama, jenis) VALUES ('gaji', 'pemasukan');
INSERT INTO kategori (nama, jenis) VALUES ('makan', 'pengeluaran');
INSERT INTO kategori (nama, jenis, parent_id)
VALUES ('makan di luar', 'pengeluaran', 2);
-- Baris sah sebagai pembanding: transaksi pengeluaran bernilai positif.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-05', 'Makan siang', 25000.00);
-- Anggaran sah: periode awal bulan dan batas positif.
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-01', 400000.00);
CREATE TABLE hasil_uji (
urutan integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kasus text NOT NULL,
hasil text NOT NULL
);
DO $$
BEGIN
-- Uji positif: data sah kedua harus diterima.
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-06', 'Kopi sore', 18000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('transaksi sah kedua diterima', 'LULUS: baris tersimpan');
-- Uji negatif: tiap pelanggaran harus ditolak database.
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-07', 'Salah ketik', -50000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi negatif', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi negatif', 'LULUS: ditolak CHECK jumlah_harus_positif');
END;
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pengeluaran', DATE '2026-01-07', 'Pengembalian nol', 0.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi nol', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jumlah transaksi nol', 'LULUS: ditolak CHECK jumlah_harus_positif');
END;
BEGIN
INSERT INTO transaksi (akun_id, kategori_id, tipe, tanggal, deskripsi, jumlah)
VALUES (1, 3, 'pemasukan', DATE '2026-01-08', 'Salah pilih tipe', 50000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('tipe pemasukan di kategori pengeluaran', 'GAGAL: baris justru diterima');
EXCEPTION WHEN foreign_key_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('tipe pemasukan di kategori pengeluaran', 'LULUS: ditolak FK tipe_cocok_dengan_kategori');
END;
BEGIN
INSERT INTO kategori (nama, jenis, parent_id)
VALUES ('bonus', 'pemasukan', 3);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('subkategori beda jenis dengan induk', 'GAGAL: baris justru diterima');
EXCEPTION WHEN foreign_key_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('subkategori beda jenis dengan induk', 'LULUS: ditolak FK induk_sama_jenis');
END;
BEGIN
INSERT INTO akun (nama, jenis, saldo_awal)
VALUES ('Kantong Ajaib', 'brankas', 100000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jenis akun tidak dikenal', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('jenis akun tidak dikenal', 'LULUS: ditolak CHECK jenis akun');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-15', 400000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('periode anggaran tanggal 15', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('periode anggaran tanggal 15', 'LULUS: ditolak CHECK periode_awal_bulan');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-04-01', 0.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('batas anggaran nol', 'GAGAL: baris justru diterima');
EXCEPTION WHEN check_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('batas anggaran nol', 'LULUS: ditolak CHECK batas_harus_positif');
END;
BEGIN
INSERT INTO anggaran (kategori_id, periode, batas_jumlah)
VALUES (3, DATE '2026-03-01', 500000.00);
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('anggaran ganda untuk kategori dan bulan sama', 'GAGAL: baris justru diterima');
EXCEPTION WHEN unique_violation THEN
INSERT INTO hasil_uji (kasus, hasil)
VALUES ('anggaran ganda untuk kategori dan bulan sama', 'LULUS: ditolak UNIQUE satu_anggaran_per_periode');
END;
END $$;
SELECT urutan, kasus, hasil
FROM hasil_uji
ORDER BY urutan;