Aplikasi keuangan pribadi menyimpan jawaban atas pertanyaan yang setiap orang tanyakan pada akhir bulan: ke mana uangku pergi, dari mana uangku datang, dan berapa sisanya. Sebelum satu baris kode PHP ditulis, kita harus memutuskan bagaimana tiga konsep itu — akun, kategori, dan transaksi — hidup di dalam database. Pada bagian ini kita merancang tiga tabel SQLite lengkap dengan constraint dan data awal, lalu membuktikan lewat PHP bahwa setiap aturan benar-benar ditegakkan. Satu keputusan desain paling penting dibuat lebih dulu: uang tidak pernah disimpan sebagai floating point, melainkan sebagai integer dalam satuan sen.
Dari Kebutuhan ke Struktur Data #
Kebutuhan aplikasi kita daftarkan dulu secara eksplisit agar setiap kolom punya alasan keberadaannya:
- Uang berada di beberapa tempat: dompet kas, rekening bank, dan dompet digital (ewallet).
- Setiap perpindahan uang adalah satu transaksi: datang (pemasukan) atau pergi (pengeluaran).
- Setiap transaksi dikelompokkan lewat kategori, misalnya Makanan dan Transportasi.
- Jumlah uang pecahan seperti bunga bank harus tersimpan eksak sampai sen terakhir.
- Setiap transaksi mencatat dua waktu: kapan peristiwa terjadi dan kapan baris ditulis.
Setiap kebutuhan itu diterjemahkan menjadi keputusan konkret pada schema, bukan sekadar niat baik di kode aplikasi:
| Kebutuhan | Keputusan pada schema |
|---|---|
| Tiga tempat uang | tabel accounts + CHECK (jenis IN ('kas','bank','ewallet')) |
| Dua arah arus uang | kolom jenis dengan CHECK pada categories dan transactions |
| Pengelompokan transaksi | foreign key category_id ke categories |
| Nilai uang eksak | kolom jumlah INTEGER dalam sen dengan CHECK (jumlah > 0) |
| Dua makna waktu | kolom terjadi_pada dan dibuat_pada yang terpisah |
| Riwayat tidak terputus | foreign key account_id yang menolak penghapusan akun bertransaksi |
Pemetaan ini menjadi kontrak antara database dan seluruh bagian proyek berikutnya. Ketika Bagian 2 mulai menulis repository, method di sana cukup percaya bahwa jumlah transaksi selalu positif dan akun yang dirujuk selalu ada.
Uang Disimpan sebagai INTEGER Sen #
Keputusan paling menentukan pada proyek ini adalah cara menyimpan nilai uang. Kandidat yang terlihat paling alami adalah FLOAT — Rp25.500,00 memang tampak seperti bilangan desimal. Namun floating point merepresentasikan pecahan biner yang tidak dapat menyimpan 0,1 secara eksak, dan pembulatan kecil itu menumpuk saat nilai dijumlahkan. Mari kita lihat buktinya:
<?php
$saldo = 0.0;
foreach ([0.1, 0.2, 0.3] as $mutasi) {
$saldo += $mutasi;
}
var_dump($saldo);Output yang Dihasilkan:
float(0.6000000000000001)Tiga mutasi yang seharusnya berjumlah 0.6 ternyata menghasilkan 0.6000000000000001. Selisih sepersekian miliar rupiah ini tampak sepele, tetapi aplikasi keuangan menjumlahkan ribuan transaksi, membandingkan saldo dengan batas budget, dan menyamakan angka pada laporan — perbandingan === pada float yang “hampir sama” akan menghasilkan false. Perilaku float di PHP sudah kita bahas tuntas pada chapter Floating Point Numbers
.
Solusinya adalah menyimpan seluruh nilai uang sebagai integer dalam satuan sen: satu rupiah sama dengan seratus sen, sehingga Rp25.500,00 tersimpan sebagai 2550000. Bilangan bulat dijumlahkan, dikurangkan, dan dibandingkan secara eksak tanpa pembulatan:
<?php
$bunga = 1234567; // Rp12.345,67 dalam sen
$jumlahBulan = 3;
$total = $bunga * $jumlahBulan;
var_dump($total);
echo intdiv($total, 100), " rupiah ", $total % 100, " sen\n";Output yang Dihasilkan:
int(3703701)
37037 rupiah 1 senPerkalian integer menghasilkan integer — tidak ada digit hilang. Konversi kembali ke rupiah hanya perlu intdiv() untuk bagian rupiah dan % 100 untuk sisa sen. Konvensi “simpan sebagai sen, tampilkan sebagai rupiah” inilah yang dipakai sistem perbankan sungguhan, dan kita pertahankan terus sampai Bagian 7.
Tabel accounts dan categories #
Kedua tabel induk kita buat lebih dulu karena transactions merujuk keduanya. Berikut definisinya sebagaimana ditulis pada berkas schema.sql:
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
jenis TEXT NOT NULL CHECK (jenis IN ('kas', 'bank', 'ewallet'))
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
jenis TEXT NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran'))
);Mari kita bedah peran setiap bagian:
id INTEGER PRIMARY KEY AUTOINCREMENT: kolomidmenjadi aliasrowiddi SQLite sehingga terisi otomatis, unik, dan tidak pernah dipakai ulang setelah baris dihapus.nama TEXT NOT NULL: akun maupun kategori tanpa nama adalah data tanpa makna; database menolaknya sejak awal.CHECK (jenis IN (...)): daftar nilai yang diizinkan ditegakkan di tingkat database. String di luar daftar — misalnya'kredit'untuk akun atau'aset'untuk kategori — ditolak dengan galat23000sebelum sempat masuk.
Pembatasan CHECK memang terduplikasi nantinya di kode PHP Bagian 2, dan itu disengaja. Validasi PHP memberi pesan yang ramah lebih awal; constraint database menjadi baris pertahanan terakhir yang tetap berdiri walau ada bug, skrip lama, atau alat lain yang menyentuh database secara langsung.
Tabel transactions dan Dua Waktu #
Tabel utama proyek ini merekam setiap perpindahan uang. Berikut definisinya:
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
category_id INTEGER NOT NULL REFERENCES categories(id),
jenis TEXT NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran')),
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
keterangan TEXT NOT NULL DEFAULT '',
terjadi_pada TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);Ada empat keputusan penting pada deklarasi tersebut:
- Dua foreign key:
account_iddancategory_idmemakaiREFERENCES. DenganPRAGMA foreign_keys = ON, transaksi yang merujuk akun atau kategori yang tidak ada langsung ditolak, dan akun yang sudah memiliki transaksi tidak bisa dihapus — riwayat keuangan tidak boleh menggantung. jumlah INTEGER NOT NULL CHECK (jumlah > 0): nilainya selalu positif dalam sen. Arah uang sudah ditunjukkanjenis, sehingga tanda minus tidak diperlukan; kombinasijenisdanjumlahpositif menghilangkan ambiguitas.terjadi_padatanpa default: ini waktu peristiwa keuangan terjadi — waktu bisnis yang harus diisi pengguna, misalnya gaji turun tanggal 2 pukul 09:00.dibuat_pada DEFAULT CURRENT_TIMESTAMP: ini waktu baris ditulis ke database — waktu sistem yang boleh terisi otomatis. Pencatatan telat (mencatat kemarin hari ini) membuat keduanya berbeda, dan laporan bulanan harus mengikutiterjadi_pada, bukandibuat_pada.
Kolom waktu bertipe TEXT format Y-m-d H:i:s. Format ISO 8601 ini urut secara leksikografis, sehingga ORDER BY terjadi_pada dan BETWEEN menghasilkan urutan waktu yang benar tanpa konversi tipe. Tiga index melengkapi tabel untuk pola query yang dipakai bagian-bagian berikutnya:
CREATE INDEX IF NOT EXISTS idx_transactions_terjadi_pada ON transactions(terjadi_pada);
CREATE INDEX IF NOT EXISTS idx_transactions_account_id ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_category_id ON transactions(category_id);Index terjadi_pada melayani laporan per periode (Bagian 3 dan 5), account_id melayani perhitungan saldo, dan category_id melayani rekap per kategori serta budget (Bagian 4).
Data Awal yang Dibekukan #
Database kosong memaksa setiap demo menyusun data dari nol, jadi akhir schema.sql menyisipkan tiga akun, delapan kategori, dan sembilan transaksi Januari 2026. Seluruh nilai waktu diisi eksplisit dan dibekukan, bukan dibiarkan pada CURRENT_TIMESTAMP:
INSERT INTO accounts (id, nama, jenis) VALUES
(1, 'Dompet Kas', 'kas'),
(2, 'Rekening BCA', 'bank'),
(3, 'GoPay', 'ewallet');
INSERT INTO categories (id, nama, jenis) VALUES
(1, 'Saldo Awal', 'pemasukan'),
(2, 'Gaji', 'pemasukan'),
(3, 'Bunga Tabungan', 'pemasukan'),
(4, 'Makanan', 'pengeluaran'),
(5, 'Transportasi', 'pengeluaran'),
(6, 'Hiburan', 'pengeluaran'),
(7, 'Belanja', 'pengeluaran'),
(8, 'Cicilan', 'pengeluaran');Kategori Saldo Awal adalah penyederhanaan yang lazim pada aplikasi keuangan pribadi: saldo awal dompet dicatat sebagai transaksi pemasukan, sehingga perhitungan saldo selalu cukup memakai satu rumus — jumlah pemasukan dikurangi jumlah pengeluaran. Sembilan transaksi awal kemudian memakai nilai sen yang sengaja tidak bulat, misalnya bunga tabungan 1234567 sen (Rp12.345,67), agar presisi integer benar-benar terlihat bekerja. Karena blok INSERT tidak punya constraint UNIQUE untuk dideduplikasi, schema.sql ini hanya boleh dimuat pada database yang masih kosong; seluruh contoh proyek ini memakai sqlite::memory: yang selalu dimulai bersih.
Menguji Struktur dari PHP #
Skema belum bisa dipercaya sebelum strukturnya terbukti. SQLite menyimpan daftar objeknya pada tabel sqlite_master, dan struktur kolom sebuah tabel bisa dibaca lewat PRAGMA table_info. Skrip berikut memuat schema.sql ke database :memory: lalu memeriksa keduanya:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
echo "Objek database hasil schema.sql:\n";
foreach ($pdo->query('SELECT type, name FROM sqlite_master ORDER BY type, name') as $objek) {
echo " {$objek['type']}: {$objek['name']}\n";
}
echo "\nStruktur kolom transactions:\n";
foreach ($pdo->query('PRAGMA table_info(transactions)') as $kolom) {
$default = $kolom['dflt_value'] ?? '-';
echo " {$kolom['name']} bertipe {$kolom['type']},"
. " wajib diisi: {$kolom['notnull']},"
. " primary key: {$kolom['pk']},"
. " nilai default: $default\n";
}Output yang Dihasilkan:
Objek database hasil schema.sql:
index: idx_transactions_account_id
index: idx_transactions_category_id
index: idx_transactions_terjadi_pada
table: accounts
table: categories
table: sqlite_sequence
table: transactions
Struktur kolom transactions:
id bertipe INTEGER, wajib diisi: 0, primary key: 1, nilai default: -
account_id bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
category_id bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
jenis bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
jumlah bertipe INTEGER, wajib diisi: 1, primary key: 0, nilai default: -
keterangan bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: ''
terjadi_pada bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: -
dibuat_pada bertipe TEXT, wajib diisi: 1, primary key: 0, nilai default: CURRENT_TIMESTAMPTujuh objek tampil dan semuanya sah: tiga tabel buatan kita, tiga index dari CREATE INDEX, plus sqlite_sequence yang dikelola SQLite sendiri untuk mengingat nomor id terakhir tabel AUTOINCREMENT. Baris id wajib diisi: 0 bukan kekeliruan — primary key INTEGER selalu terisi otomatis sehingga tidak perlu NOT NULL eksplisit. Berikutnya kita pastikan isi data awal benar-benar masuk dengan nilai sen yang kita bekukan:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
echo "Akun:\n";
foreach ($pdo->query('SELECT id, nama, jenis FROM accounts ORDER BY id') as $akun) {
echo " #{$akun['id']} {$akun['nama']} [{$akun['jenis']}]\n";
}
echo "\nTransaksi Januari 2026:\n";
$sql = 'SELECT t.id, a.nama AS akun, c.nama AS kategori, t.jenis, t.jumlah, t.keterangan, t.terjadi_pada
FROM transactions t
JOIN accounts a ON a.id = t.account_id
JOIN categories c ON c.id = t.category_id
ORDER BY t.id';
foreach ($pdo->query($sql) as $t) {
$arah = $t['jenis'] === 'pemasukan' ? '+' : '-';
echo " #{$t['id']} {$t['terjadi_pada']} {$t['akun']} {$arah}{$t['jumlah']} sen"
. " — {$t['kategori']} — {$t['keterangan']}\n";
}Output yang Dihasilkan:
Akun:
#1 Dompet Kas [kas]
#2 Rekening BCA [bank]
#3 GoPay [ewallet]
Transaksi Januari 2026:
#1 2026-01-01 08:00:00 Dompet Kas +200000000 sen — Saldo Awal — Saldo awal dompet kas
#2 2026-01-01 08:00:00 Rekening BCA +1250000000 sen — Saldo Awal — Saldo awal rekening BCA
#3 2026-01-01 08:00:00 GoPay +15000000 sen — Saldo Awal — Saldo awal GoPay
#4 2026-01-02 09:00:00 Rekening BCA +850000000 sen — Gaji — Gaji bulan Januari 2026
#5 2026-01-03 12:30:00 Dompet Kas -2550000 sen — Makanan — Makan siang di warung Bu Siti
#6 2026-01-05 07:45:00 GoPay -350000 sen — Transportasi — Naik TransJakarta
#7 2026-01-07 06:00:00 Rekening BCA -5499000 sen — Hiburan — Langganan streaming bulanan
#8 2026-01-10 08:20:00 Dompet Kas -12500000 sen — Belanja — Belanja mingguan di pasar
#9 2026-01-31 10:00:00 Rekening BCA +1234567 sen — Bunga Tabungan — Bunga tabungan DesemberNilai masih tampil sebagai angka sen mentah — format ramah manusia seperti Rp25.500,00 baru dibangun pada Bagian 2 lewat helper formatRupiah(). Query di atas juga memperlihatkan pola JOIN yang dipakai seluruh proyek: tabel transactions tidak pernah tampil sendirian, selalu bersama nama akun dan kategorinya.
Membuktikan Constraint Bekerja #
Aturan yang paling penting adalah yang paling sering dilanggar. Empat penyisipan berikut sengaja melanggar CHECK jenis, CHECK jumlah, foreign key saat menyisipkan, dan foreign key saat menghapus; keempatnya ditangkap PDO dalam mode ERRMODE_EXCEPTION:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(file_get_contents(__DIR__ . '/schema.sql'));
$sql = 'INSERT INTO transactions (account_id, category_id, jenis, jumlah, keterangan, terjadi_pada)
VALUES (?, ?, ?, ?, ?, ?)';
try {
$pdo->prepare($sql)->execute([1, 4, 'kredit', 100000, 'Jenis di luar daftar', '2026-01-15 10:00:00']);
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Jenis 'kredit' ditolak [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
try {
$pdo->prepare($sql)->execute([1, 4, 'pengeluaran', -50000, 'Jumlah negatif', '2026-01-15 10:00:00']);
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Jumlah negatif ditolak [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
try {
$pdo->prepare($sql)->execute([99, 4, 'pengeluaran', 50000, 'Akun tidak ada', '2026-01-15 10:00:00']);
echo "Tersimpan.\n";
} catch (PDOException $e) {
echo "Akun #99 ditolak [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
try {
$pdo->exec('DELETE FROM accounts WHERE id = 1');
echo "Akun #1 terhapus.\n";
} catch (PDOException $e) {
echo "Hapus akun #1 yang punya transaksi ditolak [{$e->getCode()}]\n";
echo " {$e->getMessage()}\n";
}
$jumlah = $pdo->query('SELECT COUNT(*) FROM transactions')->fetchColumn();
echo "Jumlah transaksi tetap: $jumlah\n";Output yang Dihasilkan:
Jenis 'kredit' ditolak [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: jenis IN ('pemasukan', 'pengeluaran')
Jumlah negatif ditolak [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: jumlah > 0
Akun #99 ditolak [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
Hapus akun #1 yang punya transaksi ditolak [23000]
SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed
Jumlah transaksi tetap: 9Keempat pelanggaran ditolak dengan kode SQLSTATE 23000 — kode standar untuk pelanggaran integritas. Perhatikan satu hal penting dari eksperimen ini: CHECK pada kolom jenis hanya memastikan nilainya 'pemasukan' atau 'pengeluaran', tetapi tidak bisa memastikan jenis transaksi cocok dengan jenis kategorinya — misalnya transaksi pemasukan pada kategori Makanan tidak akan ditolak schema. Aturan lintas-kolom semacam itu adalah pengetahuan domain, bukan aturan tipe data, dan akan kita tegakkan di lapisan service pada Bagian 3.
Cara Menjalankan Contoh #
Seluruh snippet pada bagian ini membutuhkan schema.sql yang tersedia pada bagian bawah halaman. Susun foldernya sebagai berikut:
aplikasi-keuangan-pribadi/
└── bagian-1-domain-dan-database/
├── schema.sql
└── uji-skema.phpSimpan salah satu snippet pengujian di atas sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:
php uji-skema.phpPHP membaca schema.sql relatif terhadap __DIR__, yaitu folder tempat uji-skema.php berada, sehingga perintah di atas bisa dijalankan dari folder mana pun asalkan kedua berkas tetap satu folder. Seluruh contoh memakai sqlite::memory:, jadi tidak ada berkas database yang tersisa setelah eksekusi. Bagian-bagian berikutnya menambah folder saudara seperti bagian-2-akun-dan-kategori/ dan merujuk schema.sql ini lewat path relatif ../bagian-1-domain-dan-database/schema.sql.
Apa yang Berubah #
Proyek dimulai dari folder kosong dan kini memiliki fondasi penyimpanan yang lengkap:
- Berkas
schema.sqlmenjadi satu-satunya sumber kebenaran struktur data: tiga tabel (accounts,categories,transactions), tiga index pada pola query utama, dan constraintCHECKserta foreign key di setiap kolom yang menahannya. - Keputusan uang-sebagai-integer-sen ditegakkan dari lapisan paling bawah: kolom
jumlahbertipeINTEGERpositif, dan seluruh contoh data memakai satuan sen. - Tiga akun, delapan kategori, dan sembilan transaksi Januari 2026 menyertai schema dengan waktu yang dibekukan, membuat setiap demo pada bagian berikutnya langsung berisi data yang reproducible.
- Batas schema juga terpetakan jujur:
CHECKmenjaga nilai kolom, sedangkan kecocokan jenis transaksi dengan jenis kategori menunggu lapisan service.
Ringkasan #
Desain data menegakkan aturan persis di tempat yang paling sulit dilanggar: database. Uang disimpan sebagai integer sen karena floating point tidak dapat diandalkan untuk perhitungan keuangan, tiga tabel diikat foreign key dan CHECK constraint, dua kolom waktu memisahkan kapan peristiwa terjadi dari kapan ia dicatat, dan data awal dibekukan agar demo dapat direproduksi. Struktur inilah yang akan dipakai berulang kali oleh lapisan PHP. Pada Bagian 2 kita menulis dua repository pertama — AccountRepository dan CategoryRepository — beserta helper formatRupiah() yang menerjemahkan sen menjadi teks rupiah yang enak dibaca.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
schema.sql
Unduh berkas-- schema.sql — Aplikasi Keuangan Pribadi
-- Basis data SQLite untuk pencatatan keuangan pribadi.
--
-- KEPUTUSAN DESAIN PENTING:
-- Seluruh nilai uang disimpan sebagai INTEGER dalam satuan SEN
-- (1 rupiah = 100 sen). Contoh: Rp25.000,00 disimpan sebagai 2500000.
-- Floating point tidak pernah dipakai untuk menyimpan uang karena
-- tidak dapat merepresentasikan pecahan desimal secara eksak.
--
-- Catatan PRAGMA foreign_keys:
-- SQLite menonaktifkan pemeriksaan foreign key secara default pada
-- setiap koneksi baru. Pragma bersifat per koneksi sehingga tidak
-- efektif ditulis di berkas ini; kode PHP-lah yang menyalakannya
-- setiap kali membuka koneksi.
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
jenis TEXT NOT NULL CHECK (jenis IN ('kas', 'bank', 'ewallet'))
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
jenis TEXT NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran'))
);
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
category_id INTEGER NOT NULL REFERENCES categories(id),
jenis TEXT NOT NULL CHECK (jenis IN ('pemasukan', 'pengeluaran')),
jumlah INTEGER NOT NULL CHECK (jumlah > 0),
keterangan TEXT NOT NULL DEFAULT '',
terjadi_pada TEXT NOT NULL,
dibuat_pada TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Index untuk pola query yang dipakai bagian-bagian berikutnya:
-- laporan per periode (terjadi_pada) dan agregasi per akun/kategori.
CREATE INDEX IF NOT EXISTS idx_transactions_terjadi_pada ON transactions(terjadi_pada);
CREATE INDEX IF NOT EXISTS idx_transactions_account_id ON transactions(account_id);
CREATE INDEX IF NOT EXISTS idx_transactions_category_id ON transactions(category_id);
-- ============================================================
-- Data awal (seed) — seluruh waktu dibekukan agar contoh pada
-- setiap bagian menghasilkan output yang sama persis.
-- Muat schema.sql ini hanya pada database yang masih kosong;
-- blok INSERT di bawah tidak memiliki penanda UNIQUE untuk
-- dideduplikasi sehingga eksekusi kedua akan menggandakan data.
-- ============================================================
INSERT INTO accounts (id, nama, jenis) VALUES
(1, 'Dompet Kas', 'kas'),
(2, 'Rekening BCA', 'bank'),
(3, 'GoPay', 'ewallet');
INSERT INTO categories (id, nama, jenis) VALUES
(1, 'Saldo Awal', 'pemasukan'),
(2, 'Gaji', 'pemasukan'),
(3, 'Bunga Tabungan', 'pemasukan'),
(4, 'Makanan', 'pengeluaran'),
(5, 'Transportasi', 'pengeluaran'),
(6, 'Hiburan', 'pengeluaran'),
(7, 'Belanja', 'pengeluaran'),
(8, 'Cicilan', 'pengeluaran');
-- jumlah dalam SEN. Contoh: 2550000 sen = Rp25.500,00.
INSERT INTO transactions
(id, account_id, category_id, jenis, jumlah, keterangan, terjadi_pada, dibuat_pada)
VALUES
(1, 1, 1, 'pemasukan', 200000000, 'Saldo awal dompet kas', '2026-01-01 08:00:00', '2026-01-01 08:00:00'),
(2, 2, 1, 'pemasukan', 1250000000, 'Saldo awal rekening BCA', '2026-01-01 08:00:00', '2026-01-01 08:00:00'),
(3, 3, 1, 'pemasukan', 15000000, 'Saldo awal GoPay', '2026-01-01 08:00:00', '2026-01-01 08:00:00'),
(4, 2, 2, 'pemasukan', 850000000, 'Gaji bulan Januari 2026', '2026-01-02 09:00:00', '2026-01-02 09:05:00'),
(5, 1, 4, 'pengeluaran', 2550000, 'Makan siang di warung Bu Siti', '2026-01-03 12:30:00', '2026-01-03 12:35:00'),
(6, 3, 5, 'pengeluaran', 350000, 'Naik TransJakarta', '2026-01-05 07:45:00', '2026-01-05 07:50:00'),
(7, 2, 6, 'pengeluaran', 5499000, 'Langganan streaming bulanan', '2026-01-07 06:00:00', '2026-01-07 06:10:00'),
(8, 1, 7, 'pengeluaran', 12500000, 'Belanja mingguan di pasar', '2026-01-10 08:20:00', '2026-01-10 08:25:00'),
(9, 2, 3, 'pemasukan', 1234567, 'Bunga tabungan Desember', '2026-01-31 10:00:00', '2026-01-31 10:00:00');