Skema dari Bagian 1 sudah menolak data sampah, tetapi belum ada cara memasukkan data yang sah. Pada bagian ini kita membangun program utamanya: menu interaktif yang menunggu perintah pengguna, mengumpulkan masukan, lalu menyimpannya lewat fungsi di db.py. Validasinya bekerja dua lapis — format diperiksa oleh Python sebelum query dijalankan, sedangkan aturan bisnis tetap dijaga CHECK constraint di database — sehingga salah satu lapis gagal pun data tetap aman.

Dua Fungsi Baru di db.py #

Program utama butuh dua operasi data: menyimpan satu transaksi dan mengambil yang terbaru. Keduanya ditambahkan ke db.py, lengkap dengan satu pembantu kecil tampilan:

python
def format_rupiah(jumlah):
    """Format angka menjadi teks rupiah, mis. 5500000 menjadi Rp5.500.000."""
    return f"Rp{jumlah:,.0f}".replace(",", ".")


def insert_transaksi(tanggal, jenis, kategori, jumlah, catatan="", path=NAMA_DB):
    """Simpan satu transaksi, kembalikan id baris baru."""
    with closing(get_connection(path)) as con:
        with con:
            cur = con.execute(
                "INSERT INTO transaksi (tanggal, jenis, kategori, jumlah, catatan)"
                " VALUES (?, ?, ?, ?, ?)",
                (tanggal, jenis, kategori, jumlah, catatan),
            )
            return cur.lastrowid


def transaksi_terakhir(batas=10, path=NAMA_DB):
    """Ambil sejumlah transaksi terbaru, ditata dari yang paling baru."""
    with closing(get_connection(path)) as con:
        return con.execute(
            "SELECT id, tanggal, jenis, kategori, jumlah, catatan"
            " FROM transaksi ORDER BY id DESC LIMIT ?",
            (batas,),
        ).fetchall()

Mari kita bedah bagian pentingnya:

  1. Fungsi insert_transaksi(): seluruh nilai pengguna mengalir lewat placeholder ? — prinsip utama Chapter Query Berparameter. String SQL tidak pernah dirangkai dari masukan pengguna, jadi tanda kutip atau -- pada catatan tidak bisa mengubah makna query.
  2. with con: di dalam closing(): transaction dikelola otomatis — commit saat sukses, rollback saat sqlite3.IntegrityError terjadi, seperti dibahas pada Chapter Transaksi dengan Python. Koneksi tetap ditutup oleh closing() di kedua kemungkinan itu.
  3. return cur.lastrowid: cursor menyimpan id baris yang baru disisipkan; nilai ini kita kembalikan agar program bisa melaporkannya ke pengguna.
  4. Fungsi transaksi_terakhir(): ORDER BY id DESC menata baris terbaru di depan, LIMIT ? membatasi jumlahnya — batas pun dikirim sebagai parameter, bukan digabung ke string SQL.
  5. Fungsi format_rupiah(): pembantu kecil yang tidak menyentuh SQL sama sekali — ia hanya mengubah 5500000 menjadi Rp5.500.000 dengan format ribuan gaya Indonesia. Kita letakkan di db.py karena akan dipakai dua modul tampilan: main.py sekarang dan laporan.py pada Bagian 3.

main.py: Menu dan Validasi #

Berikut seluruh isi main.py untuk tahap ini. Menu mengikuti pola loop yang sama dengan program terminal pada umumnya: tampilkan pilihan, baca satu baris, arahkan ke tindakannya:

python
"""Pencatat Keuangan Pribadi — aplikasi CLI berbasis SQLite."""

import sqlite3
from datetime import date

import db


def tampilkan_menu():
    print("=== Pencatat Keuangan ===")
    print("1. Catat pemasukan")
    print("2. Catat pengeluaran")
    print("3. Lihat transaksi terakhir")
    print("4. Keluar")


def baca_tanggal():
    while True:
        teks = input("Tanggal (YYYY-MM-DD, Enter = hari ini): ").strip()
        if not teks:
            return date.today().isoformat()
        try:
            return date.fromisoformat(teks).isoformat()
        except ValueError:
            print("Format tanggal harus YYYY-MM-DD, mis. 2025-06-15.")


def baca_jumlah():
    while True:
        teks = input("Jumlah (Rp): ").strip()
        try:
            return float(teks)
        except ValueError:
            print("Jumlah harus berupa angka, mis. 25000.")


def baca_teks(label):
    while True:
        teks = input(label).strip()
        if teks:
            return teks
        print("Masukan tidak boleh kosong.")


def catat_transaksi(jenis):
    if jenis == "masuk":
        print("--- Catat Pemasukan ---")
    else:
        print("--- Catat Pengeluaran ---")
    tanggal = baca_tanggal()
    kategori = baca_teks("Kategori: ")
    jumlah = baca_jumlah()
    catatan = input("Catatan (opsional): ").strip()

    try:
        id_baru = db.insert_transaksi(tanggal, jenis, kategori, jumlah, catatan)
    except sqlite3.IntegrityError as e:
        print(f"Transaksi ditolak database: {e}")
        return
    print(f"Transaksi tersimpan dengan id {id_baru}.")


def tampilkan_transaksi():
    daftar = db.transaksi_terakhir()
    if not daftar:
        print("Belum ada transaksi.")
        return
    print(f"{'id':>3}  {'tanggal':<10}  {'jenis':<7}  {'kategori':<14}  {'jumlah':>12}")
    for t in daftar:
        print(
            f"{t['id']:>3}  {t['tanggal']:<10}  {t['jenis']:<7}  "
            f"{t['kategori']:<14}  {db.format_rupiah(t['jumlah']):>12}"
        )


def main():
    db.init_db()
    while True:
        tampilkan_menu()
        pilihan = input("Pilihan: ").strip()

        if pilihan == "1":
            catat_transaksi("masuk")
        elif pilihan == "2":
            catat_transaksi("keluar")
        elif pilihan == "3":
            tampilkan_transaksi()
        elif pilihan == "4":
            print("Sampai jumpa!")
            break
        else:
            print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()

Beberapa keputusan penting di dalamnya:

  1. Fungsi baca_tanggal(): date.fromisoformat() menolak teks seperti '15-06-2025' maupun '2025-6-5' dengan ValueError, dan nilai baliknya di-isoformat() ulang agar bentuknya pasti YYYY-MM-DD. Menekan Enter tanpa mengetik apa pun memakai date.today().isoformat() — pada sesi berikut kita selalu mengetik tanggal tetap agar hasilnya dapat direproduksi.
  2. Fungsi baca_jumlah(): float() menolak teks seperti lima ribu, tetapi menerima -5000. Angka negatif bukan masalah format — ia masalah aturan bisnis.
  3. except sqlite3.IntegrityError pada catat_transaksi(): inilah lapis kedua. Saat float() menerima -5000, query berjalan sampai database dan CHECK constraint jumlah > 0 yang menolaknya. Program menangkap sqlite3.IntegrityError (Chapter Penanganan Error sqlite3), mencetak pesannya, lalu kembali ke menu.
  4. Fungsi tampilkan_transaksi(): hasil fetchall() berupa daftar sqlite3.Row, sehingga t['kategori'] mengambil kolom berdasarkan nama — keuntungan langsung dari row_factory yang dipasang get_connection().
  5. db.init_db() di awal main(): berkat IF NOT EXISTS, program bisa dijalankan di folder kosong sekalipun — tabel dibuat pada eksekusi pertama dan dibiarkan utuh pada eksekusi berikutnya.

Menjalankan Program #

Karena program interaktif sulit ditangkap layar, kita meniru ketikan pengguna lewat pipe. Sesi berikut mencatat tiga transaksi — satu pemasukan dan dua pengeluaran — sambil menyisipkan tiga kesalahan ketik: tanggal salah format, jumlah bukan angka, dan jumlah negatif yang ditolak database:

bash
rm -f keuangan.db
echo "1
2025-06-01
Gaji
5500000
Gaji bulanan
2
15-06-2025
2025-06-03
Makan
45000
Makan siang
2
2025-06-05
Transportasi
lima ribu
25000

2
2025-06-07
Hiburan
-5000

3
4" | python3 main.py

Output yang Dihasilkan:

text
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan: --- Catat Pemasukan ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Catatan (opsional): Transaksi tersimpan dengan id 1.
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan: --- Catat Pengeluaran ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Format tanggal harus YYYY-MM-DD, mis. 2025-06-15.
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Catatan (opsional): Transaksi tersimpan dengan id 2.
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan: --- Catat Pengeluaran ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Jumlah harus berupa angka, mis. 25000.
Jumlah (Rp): Catatan (opsional): Transaksi tersimpan dengan id 3.
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan: --- Catat Pengeluaran ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Catatan (opsional): Transaksi ditolak database: CHECK constraint failed: jumlah > 0
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan:  id  tanggal     jenis    kategori              jumlah
  3  2025-06-05  keluar   Transportasi        Rp25.000
  2  2025-06-03  keluar   Makan               Rp45.000
  1  2025-06-01  masuk    Gaji             Rp5.500.000
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Keluar
Pilihan: Sampai jumpa!

Perhatikan baris keempat: masukan -5000 diterima float(), lolos dari format check Python, lalu ditolak oleh CHECK constraint di database — dua lapis validasi bekerja tepat pada wilayahnya masing-masing. Setelah program berakhir, berkas keuangan.db menyimpan tiga transaksi; eksekusi berikutnya menambah baris baru tanpa mengulang sesi ini.

Program pencatat kini hidup: menu berjalan, transaksi tersimpan, dan tampilan daftar rapi. Seluruh berkas tahap ini tersedia pada bagian Berkas Sumber di bawah halaman. Bagian 3 mengubah tumpukan baris tersebut menjadi jawaban: berapa saldo bulan ini dan ke mana uangnya pergi.

Berkas Sumber

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

db.py

Unduh berkas
python
"""Modul akses data Pencatat Keuangan Pribadi.

Satu-satunya tempat yang membuka koneksi database dan menjalankan SQL;
modul lain cukup memanggil fungsi-fungsi di bawah.
"""

import sqlite3
from contextlib import closing
from pathlib import Path

NAMA_DB = "keuangan.db"
BERKAS_SKEMA = Path(__file__).with_name("schema.sql")


def get_connection(path=NAMA_DB):
    """Buka koneksi baru dengan pengaturan seragam untuk seluruh aplikasi."""
    con = sqlite3.connect(path)
    con.row_factory = sqlite3.Row
    con.execute("PRAGMA foreign_keys = ON")
    con.execute("PRAGMA busy_timeout = 5000")
    return con


def init_db(path=NAMA_DB):
    """Jalankan schema.sql; aman dipanggil berulang karena memakai IF NOT EXISTS."""
    skema = BERKAS_SKEMA.read_text(encoding="utf-8")
    with closing(get_connection(path)) as con:
        con.executescript(skema)


def format_rupiah(jumlah):
    """Format angka menjadi teks rupiah, mis. 5500000 menjadi Rp5.500.000."""
    return f"Rp{jumlah:,.0f}".replace(",", ".")


def insert_transaksi(tanggal, jenis, kategori, jumlah, catatan="", path=NAMA_DB):
    """Simpan satu transaksi, kembalikan id baris baru."""
    with closing(get_connection(path)) as con:
        with con:
            cur = con.execute(
                "INSERT INTO transaksi (tanggal, jenis, kategori, jumlah, catatan)"
                " VALUES (?, ?, ?, ?, ?)",
                (tanggal, jenis, kategori, jumlah, catatan),
            )
            return cur.lastrowid


def transaksi_terakhir(batas=10, path=NAMA_DB):
    """Ambil sejumlah transaksi terbaru, ditata dari yang paling baru."""
    with closing(get_connection(path)) as con:
        return con.execute(
            "SELECT id, tanggal, jenis, kategori, jumlah, catatan"
            " FROM transaksi ORDER BY id DESC LIMIT ?",
            (batas,),
        ).fetchall()

main.py

Unduh berkas
python
"""Pencatat Keuangan Pribadi — aplikasi CLI berbasis SQLite."""

import sqlite3
from datetime import date

import db


def tampilkan_menu():
    print("=== Pencatat Keuangan ===")
    print("1. Catat pemasukan")
    print("2. Catat pengeluaran")
    print("3. Lihat transaksi terakhir")
    print("4. Keluar")


def baca_tanggal():
    while True:
        teks = input("Tanggal (YYYY-MM-DD, Enter = hari ini): ").strip()
        if not teks:
            return date.today().isoformat()
        try:
            return date.fromisoformat(teks).isoformat()
        except ValueError:
            print("Format tanggal harus YYYY-MM-DD, mis. 2025-06-15.")


def baca_jumlah():
    while True:
        teks = input("Jumlah (Rp): ").strip()
        try:
            return float(teks)
        except ValueError:
            print("Jumlah harus berupa angka, mis. 25000.")


def baca_teks(label):
    while True:
        teks = input(label).strip()
        if teks:
            return teks
        print("Masukan tidak boleh kosong.")


def catat_transaksi(jenis):
    if jenis == "masuk":
        print("--- Catat Pemasukan ---")
    else:
        print("--- Catat Pengeluaran ---")
    tanggal = baca_tanggal()
    kategori = baca_teks("Kategori: ")
    jumlah = baca_jumlah()
    catatan = input("Catatan (opsional): ").strip()

    try:
        id_baru = db.insert_transaksi(tanggal, jenis, kategori, jumlah, catatan)
    except sqlite3.IntegrityError as e:
        print(f"Transaksi ditolak database: {e}")
        return
    print(f"Transaksi tersimpan dengan id {id_baru}.")


def tampilkan_transaksi():
    daftar = db.transaksi_terakhir()
    if not daftar:
        print("Belum ada transaksi.")
        return
    print(f"{'id':>3}  {'tanggal':<10}  {'jenis':<7}  {'kategori':<14}  {'jumlah':>12}")
    for t in daftar:
        print(
            f"{t['id']:>3}  {t['tanggal']:<10}  {t['jenis']:<7}  "
            f"{t['kategori']:<14}  {db.format_rupiah(t['jumlah']):>12}"
        )


def main():
    db.init_db()
    while True:
        tampilkan_menu()
        pilihan = input("Pilihan: ").strip()

        if pilihan == "1":
            catat_transaksi("masuk")
        elif pilihan == "2":
            catat_transaksi("keluar")
        elif pilihan == "3":
            tampilkan_transaksi()
        elif pilihan == "4":
            print("Sampai jumpa!")
            break
        else:
            print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()

schema.sql

Unduh berkas
sql
-- Skema Pencatat Keuangan Pribadi.
-- Satu tabel transaksi menampung pemasukan dan pengeluaran sekaligus.

CREATE TABLE IF NOT EXISTS transaksi (
    id       INTEGER PRIMARY KEY,
    tanggal  TEXT NOT NULL CHECK (tanggal IS date(tanggal)),
    jenis    TEXT NOT NULL CHECK (jenis IN ('masuk', 'keluar')),
    kategori TEXT NOT NULL,
    jumlah   REAL NOT NULL CHECK (jumlah > 0),
    catatan  TEXT NOT NULL DEFAULT ''
);