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:

KebutuhanKeputusan pada schema
Tiga tempat uangtabel accounts + CHECK (jenis IN ('kas','bank','ewallet'))
Dua arah arus uangkolom jenis dengan CHECK pada categories dan transactions
Pengelompokan transaksiforeign key category_id ke categories
Nilai uang eksakkolom jumlah INTEGER dalam sen dengan CHECK (jumlah > 0)
Dua makna waktukolom terjadi_pada dan dibuat_pada yang terpisah
Riwayat tidak terputusforeign 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
<?php
$saldo = 0.0;
foreach ([0.1, 0.2, 0.3] as $mutasi) {
    $saldo += $mutasi;
}
var_dump($saldo);

Output yang Dihasilkan:

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

text
int(3703701)
37037 rupiah 1 sen

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

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:

  1. id INTEGER PRIMARY KEY AUTOINCREMENT: kolom id menjadi alias rowid di SQLite sehingga terisi otomatis, unik, dan tidak pernah dipakai ulang setelah baris dihapus.
  2. nama TEXT NOT NULL: akun maupun kategori tanpa nama adalah data tanpa makna; database menolaknya sejak awal.
  3. 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 galat 23000 sebelum 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:

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

  1. Dua foreign key: account_id dan category_id memakai REFERENCES. Dengan PRAGMA 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.
  2. jumlah INTEGER NOT NULL CHECK (jumlah > 0): nilainya selalu positif dalam sen. Arah uang sudah ditunjukkan jenis, sehingga tanda minus tidak diperlukan; kombinasi jenis dan jumlah positif menghilangkan ambiguitas.
  3. terjadi_pada tanpa default: ini waktu peristiwa keuangan terjadi — waktu bisnis yang harus diisi pengguna, misalnya gaji turun tanggal 2 pukul 09:00.
  4. 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 mengikuti terjadi_pada, bukan dibuat_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:

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

sql
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
<?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:

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

Tujuh 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
<?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:

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

Nilai 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
<?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:

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

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

text
aplikasi-keuangan-pribadi/
└── bagian-1-domain-dan-database/
    ├── schema.sql
    └── uji-skema.php

Simpan salah satu snippet pengujian di atas sebagai uji-skema.php di samping schema.sql, lalu jalankan dari dalam folder tersebut:

bash
php uji-skema.php

PHP 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.sql menjadi satu-satunya sumber kebenaran struktur data: tiga tabel (accounts, categories, transactions), tiga index pada pola query utama, dan constraint CHECK serta foreign key di setiap kolom yang menahannya.
  • Keputusan uang-sebagai-integer-sen ditegakkan dari lapisan paling bawah: kolom jumlah bertipe INTEGER positif, 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: CHECK menjaga 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
sql
-- 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');