Program kita bekerja penuh, tetapi dua risiko masih terbuka. Pertama, setiap perubahan kode — sekecil apa pun — bisa diam-diam merusak perilaku yang sudah benar. Kedua, seluruh catatan keuangan hidup dalam satu berkas keuangan.db; satu berkas terhapus atau rusak, seluruh riwayat ikut lenyap. Pada bagian penutup ini kita menambal keduanya: test otomatis yang mengunci perilaku, lalu backup serta restore yang terverifikasi.

test_pencatat.py #

Chapter Testing Database memperkenalkan dua pilihan isolasi: database :memory: atau berkas sementara. Proyek ini memakai pola koneksi per operasi — setiap fungsi db.py membuka koneksi barunya sendiri — dan koneksi :memory: yang baru selalu berupa database kosong. Data yang disisipkan lewat satu koneksi jadi tidak terlihat dari koneksi berikutnya, sehingga test kita memakai berkas nyata di dalam fixture tmp_path: folder sementara unik per test yang otomatis dibersihkan pytest. Berikut isi lengkap test_pencatat.py:

python
"""Test untuk Pencatat Keuangan Pribadi."""

import sqlite3

import pytest

import backup
import db
import laporan


@pytest.fixture
def path_db(tmp_path):
    """Siapkan database bersih dengan skema sudah dibuat."""
    path = tmp_path / "keuangan.db"
    db.init_db(path)
    return path


def isi_contoh(path):
    """Isi database dengan transaksi tetap dari dua bulan."""
    data = [
        ("2025-06-01", "masuk", "Gaji", 5500000, "Gaji bulanan"),
        ("2025-06-03", "keluar", "Makan", 45000, "Makan siang"),
        ("2025-06-05", "keluar", "Transportasi", 25000, "Ojek ke kantor"),
        ("2025-06-20", "keluar", "Makan", 38000, "Makan malam"),
        ("2025-07-01", "masuk", "Gaji", 5500000, "Gaji bulanan"),
        ("2025-07-02", "keluar", "Hiburan", 50000, "Tiket bioskop"),
    ]
    for baris in data:
        db.insert_transaksi(*baris, path=path)


def test_init_db_bisa_dipanggil_berulang(tmp_path):
    path = tmp_path / "keuangan.db"
    db.init_db(path)
    db.init_db(path)

    con = db.get_connection(path)
    jumlah_tabel = con.execute(
        "SELECT COUNT(*) FROM sqlite_master WHERE name = 'transaksi'"
    ).fetchone()[0]
    con.close()
    assert jumlah_tabel == 1


def test_insert_dan_baca_bolak_balik(path_db):
    id_baru = db.insert_transaksi(
        "2025-06-01", "masuk", "Gaji", 5500000, "Gaji bulanan", path=path_db
    )

    hasil = db.transaksi_terakhir(path=path_db)
    assert len(hasil) == 1
    baris = hasil[0]
    assert baris["id"] == id_baru
    assert baris["tanggal"] == "2025-06-01"
    assert baris["jenis"] == "masuk"
    assert baris["kategori"] == "Gaji"
    assert baris["jumlah"] == 5500000
    assert baris["catatan"] == "Gaji bulanan"


def test_transaksi_terakhir_urutan_dan_batas(path_db):
    isi_contoh(path_db)

    hasil = db.transaksi_terakhir(batas=4, path=path_db)

    assert [t["id"] for t in hasil] == [6, 5, 4, 3]


def test_jumlah_harus_positif(path_db):
    for jumlah_buruk in (0, -5000):
        with pytest.raises(sqlite3.IntegrityError):
            db.insert_transaksi("2025-06-01", "masuk", "Gaji", jumlah_buruk, path=path_db)


def test_jenis_harus_masuk_atau_keluar(path_db):
    with pytest.raises(sqlite3.IntegrityError):
        db.insert_transaksi("2025-06-01", "transfer", "Lain-lain", 10000, path=path_db)


def test_tanggal_harus_format_iso(path_db):
    with pytest.raises(sqlite3.IntegrityError):
        db.insert_transaksi("15-06-2025", "masuk", "Gaji", 5500000, path=path_db)


def test_ringkasan_bulanan(path_db):
    isi_contoh(path_db)

    ringkasan = laporan.ringkasan_bulanan("2025-06", path=path_db)

    assert ringkasan["total_masuk"] == 5500000
    assert ringkasan["total_keluar"] == 108000
    assert ringkasan["saldo"] == 5392000


def test_ringkasan_bulan_tanpa_transaksi(path_db):
    ringkasan = laporan.ringkasan_bulanan("2025-12", path=path_db)

    assert ringkasan["total_masuk"] == 0
    assert ringkasan["total_keluar"] == 0
    assert ringkasan["saldo"] == 0


def test_kategori_terbesar(path_db):
    isi_contoh(path_db)

    hasil = laporan.kategori_terbesar("2025-06", path=path_db)
    assert [(r["kategori"], r["jumlah_transaksi"], r["total"]) for r in hasil] == [
        ("Makan", 2, 83000),
        ("Transportasi", 1, 25000),
    ]

    satu = laporan.kategori_terbesar("2025-06", batas=1, path=path_db)
    assert [r["kategori"] for r in satu] == ["Makan"]


def test_backup_dan_restore(tmp_path):
    path_db = tmp_path / "keuangan.db"
    db.init_db(path_db)
    isi_contoh(path_db)
    path_backup = tmp_path / "keuangan-salinan.db"

    backup.buat_backup(path_db, path_backup)
    assert backup.verifikasi_integritas(path_backup)

    db.insert_transaksi("2025-07-10", "keluar", "Makan", 30000, path=path_db)
    assert len(db.transaksi_terakhir(path=path_db)) == 7

    backup.restore_backup(path_backup, path_db)
    assert len(db.transaksi_terakhir(path=path_db)) == 6
    assert backup.verifikasi_integritas(path_db)


def test_restore_berkas_rusak(tmp_path):
    path_rusak = tmp_path / "rusak.db"
    path_rusak.write_text("ini bukan berkas database", encoding="utf-8")

    with pytest.raises(sqlite3.DatabaseError):
        backup.restore_backup(path_rusak, tmp_path / "tujuan.db")

Sebelas test tersebut mengunci perilaku berlapis:

  1. Fixture path_db: setiap test menerima berkas database bersih yang skemanya sudah dibuat — test tidak pernah saling menimpa, dan keuangan.db milik pembaca tidak pernah tersentuh.
  2. test_insert_dan_baca_bolak_balik dan test_transaksi_terakhir_urutan_dan_batas: menyisipkan lalu membaca kembali; urutan ORDER BY id DESC dan pembatasan LIMIT ? diverifikasi sampai ke isi barisnya.
  3. Tiga test constraint: jumlah nol dan negatif, jenis di luar daftar, serta tanggal salah format harus menaikkan sqlite3.IntegrityError — inilah bukti bahwa lapis kedua dari Bagian 2 benar-benar bekerja.
  4. Dua test laporan: agregasi CASE dan GROUP BY diuji dengan angka yang dihitung tangan (108000 pengeluaran, 5392000 saldo), termasuk bulan tanpa transaksi yang harus menghasilkan nol — bukan NULL.
  5. Dua test backup: salinan cadangan berisi data saat backup dibuat, restore mengembalikan jumlah baris lama, dan berkas bukan database ditolak dengan sqlite3.DatabaseError.

Menjalankan Test #

Dari folder yang berisi keenam berkas proyek, jalankan pytest:

bash
python3 -m pytest -v

Output yang Dihasilkan:

text
============================= test session starts ==============================
platform linux -- Python 3.12.15, pytest-9.0.2, pluggy-1.6.0
rootdir: /tmp/pencatat-keuangan
collecting ... collected 11 items

test_pencatat.py::test_init_db_bisa_dipanggil_berulang PASSED            [  9%]
test_pencatat.py::test_insert_dan_baca_bolak_balik PASSED                [ 18%]
test_pencatat.py::test_transaksi_terakhir_urutan_dan_batas PASSED        [ 27%]
test_pencatat.py::test_jumlah_harus_positif PASSED                       [ 36%]
test_pencatat.py::test_jenis_harus_masuk_atau_keluar PASSED              [ 45%]
test_pencatat.py::test_tanggal_harus_format_iso PASSED                   [ 54%]
test_pencatat.py::test_ringkasan_bulanan PASSED                          [ 63%]
test_pencatat.py::test_ringkasan_bulan_tanpa_transaksi PASSED            [ 72%]
test_pencatat.py::test_kategori_terbesar PASSED                           [ 81%]
test_pencatat.py::test_backup_dan_restore PASSED                         [ 90%]
test_pencatat.py::test_restore_berkas_rusak PASSED                       [100%]

============================== 11 passed in 0.08s ===============================

Baris rootdir dan beberapa baris header lain mengikuti lokasi proyek serta plugin yang terpasang di masing-masing mesin — yang penting adalah sebelas baris PASSED dan ringkasan 11 passed. Dari titik ini, setiap perubahan pada db.py, laporan.py, atau backup.py cukup diverifikasi dengan menjalankan kembali satu perintah yang sama.

backup.py #

Menyalin berkas .db dengan file manager atau cp saat program sedang berjalan adalah pertaruhan: salinan bisa menangkap keadaan setengah tertulis. Backup API milik SQLite menyelesaikannya dengan menyalin page demi page di bawah transaction yang konsisten — materi Chapter Backup dan Restore membahas mekanismenya. Modul sqlite3 Python menyediakannya sebagai method backup() pada connection sejak Python 3.7. Berikut isi backup.py:

python
"""Backup dan restore database Pencatat Keuangan Pribadi."""

import sqlite3
from contextlib import closing
from pathlib import Path

import db


def buat_backup(path_sumber, path_tujuan):
    """Salin database secara konsisten dengan backup API, lalu verifikasi hasil."""
    with closing(db.get_connection(path_sumber)) as sumber:
        with closing(sqlite3.connect(path_tujuan)) as tujuan:
            sumber.backup(tujuan)
    if not verifikasi_integritas(path_tujuan):
        raise sqlite3.DatabaseError(f"Integritas {path_tujuan} tidak lolos uji.")
    return path_tujuan


def restore_backup(path_backup, path_tujuan):
    """Pulihkan database dari berkas backup, lalu verifikasi hasilnya."""
    with closing(db.get_connection(path_backup)) as sumber:
        with closing(sqlite3.connect(path_tujuan)) as tujuan:
            sumber.backup(tujuan)
    if not verifikasi_integritas(path_tujuan):
        raise sqlite3.DatabaseError(f"Integritas {path_tujuan} tidak lolos uji.")
    return path_tujuan


def verifikasi_integritas(path):
    """Jalankan PRAGMA integrity_check pada satu berkas database."""
    with closing(db.get_connection(path)) as con:
        hasil = con.execute("PRAGMA integrity_check").fetchone()
        return hasil[0] == "ok"


def main():
    while True:
        print("=== Backup dan Restore ===")
        print("1. Buat backup")
        print("2. Restore dari backup")
        print("3. Keluar")
        pilihan = input("Pilihan: ").strip()

        if pilihan == "1":
            if not Path(db.NAMA_DB).exists():
                print(f"Berkas {db.NAMA_DB} belum ada — jalankan main.py dulu.")
                continue
            tujuan = input("Nama berkas backup: ").strip()
            if not tujuan:
                print("Nama berkas tidak boleh kosong.")
                continue
            try:
                buat_backup(db.NAMA_DB, tujuan)
            except sqlite3.DatabaseError as e:
                print(f"Backup gagal: {e}")
                continue
            print(f"Backup {db.NAMA_DB} tersimpan ke {tujuan}.")
            print("Verifikasi integritas: ok")
        elif pilihan == "2":
            sumber = input("Nama berkas backup: ").strip()
            if not sumber:
                print("Nama berkas tidak boleh kosong.")
                continue
            if not Path(sumber).exists():
                print(f"Berkas {sumber} tidak ditemukan.")
                continue
            try:
                restore_backup(sumber, db.NAMA_DB)
            except sqlite3.DatabaseError as e:
                print(f"Restore gagal: {e}")
                continue
            print(f"Database {db.NAMA_DB} dipulihkan dari {sumber}.")
            print("Verifikasi integritas: ok")
        elif pilihan == "3":
            print("Sampai jumpa!")
            break
        else:
            print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()

Mari kita bedah bagiannya:

  1. sumber.backup(tujuan): connection sumber menyalin seluruh isinya ke connection tujuan. Arah penyalinan menentukan maknanya — dari keuangan.db ke berkas lain berarti membuat backup; dari berkas backup ke keuangan.db berarti restore. Isi tujuan digantikan seluruhnya, bukan digabung.
  2. verifikasi_integritas(): PRAGMA integrity_check memeriksa struktur berkas dari page pertama sampai terakhir dan mengembalikan baris ok bila sehat. Kedua fungsi lain memanggilnya setelah menyalin — cadangan yang gagal uji langsung menaikkan sqlite3.DatabaseError, bukan dibiarkan diam-diam.
  3. Pemeriksaan Path(...).exists() di menu: sqlite3.connect() diam-diam membuat berkas baru bila belum ada. Tanpa pemeriksaan ini, backup dari database yang belum pernah dibuat akan menghasilkan cadangan kosong, dan restore dari nama berkas yang salah tipis akan menghapus data dengan database hampa.
  4. main.py tidak berubah pada bagian ini — backup berdiri sebagai alat terpisah, lengkap dengan penanganan sqlite3.DatabaseError untuk berkas yang rusak atau bukan database.

Menjalankan Backup dan Restore #

Sesi berikut melanjutkan keadaan akhir Bagian 3: keuangan.db berisi empat transaksi bulan Juni. Kita buat cadangannya terlebih dahulu:

bash
echo "1
keuangan-salinan.db
3" | python3 backup.py

Output yang Dihasilkan:

text
=== Backup dan Restore ===
1. Buat backup
2. Restore dari backup
3. Keluar
Pilihan: Nama berkas backup: Backup keuangan.db tersimpan ke keuangan-salinan.db.
Verifikasi integritas: ok
=== Backup dan Restore ===
1. Buat backup
2. Restore dari backup
3. Keluar
Pilihan: Sampai jumpa!

Bayangkan satu minggu kemudian sebuah transaksi keliru tercatat — atau katakanlah kita sedang mencoba-coba fitur dan data berantakan. Tambahkan satu transaksi untuk menirukan keadaan itu:

bash
python3 -c "
import db
id_baru = db.insert_transaksi('2025-06-25', 'keluar', 'Hiburan', 50000, 'Tiket bioskop')
print('Transaksi tersimpan dengan id', id_baru)
"

Output yang Dihasilkan:

text
Transaksi tersimpan dengan id 5.

Sekarang pulihkan keadaan saat backup dibuat:

bash
echo "2
keuangan-salinan.db
3" | python3 backup.py

Output yang Dihasilkan:

text
=== Backup dan Restore ===
1. Buat backup
2. Restore dari backup
3. Keluar
Pilihan: Nama berkas backup: Database keuangan.db dipulihkan dari keuangan-salinan.db.
Verifikasi integritas: ok
=== Backup dan Restore ===
1. Buat backup
2. Restore dari backup
3. Keluar
Pilihan: Sampai jumpa!

Transaksi dengan id 5 seharusnya lenyap. Menu utama membuktikannya:

bash
echo "3
5" | python3 main.py

Output yang Dihasilkan:

text
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Laporan bulanan
5. Keluar
Pilihan:  id  tanggal     jenis    kategori              jumlah
  4  2025-06-20  keluar   Makan               Rp38.000
  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. Laporan bulanan
5. Keluar
Pilihan: Sampai jumpa!

Proyek Pencatat Keuangan Pribadi kini tuntas: skema yang menjaga dirinya sendiri, menu interaktif dengan validasi dua lapis, laporan agregat dari tiga query, sebelas test yang hijau, dan backup yang terverifikasi. Keenam berkas tahap akhir ini tersedia pada bagian Berkas Sumber di bawah halaman. Dari sini banyak arah pengembangan alami: tabel kategori terpisah yang dinormalisasi, index untuk laporan rentang tanggal, filter per kategori di daftar transaksi, atau export laporan ke CSV — semuanya tinggal menulis test untuk perilaku barunya terlebih dahulu.

Berkas Sumber

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

backup.py

Unduh berkas
python
"""Backup dan restore database Pencatat Keuangan Pribadi."""

import sqlite3
from contextlib import closing
from pathlib import Path

import db


def buat_backup(path_sumber, path_tujuan):
    """Salin database secara konsisten dengan backup API, lalu verifikasi hasil."""
    with closing(db.get_connection(path_sumber)) as sumber:
        with closing(sqlite3.connect(path_tujuan)) as tujuan:
            sumber.backup(tujuan)
    if not verifikasi_integritas(path_tujuan):
        raise sqlite3.DatabaseError(f"Integritas {path_tujuan} tidak lolos uji.")
    return path_tujuan


def restore_backup(path_backup, path_tujuan):
    """Pulihkan database dari berkas backup, lalu verifikasi hasilnya."""
    with closing(db.get_connection(path_backup)) as sumber:
        with closing(sqlite3.connect(path_tujuan)) as tujuan:
            sumber.backup(tujuan)
    if not verifikasi_integritas(path_tujuan):
        raise sqlite3.DatabaseError(f"Integritas {path_tujuan} tidak lolos uji.")
    return path_tujuan


def verifikasi_integritas(path):
    """Jalankan PRAGMA integrity_check pada satu berkas database."""
    with closing(db.get_connection(path)) as con:
        hasil = con.execute("PRAGMA integrity_check").fetchone()
        return hasil[0] == "ok"


def main():
    while True:
        print("=== Backup dan Restore ===")
        print("1. Buat backup")
        print("2. Restore dari backup")
        print("3. Keluar")
        pilihan = input("Pilihan: ").strip()

        if pilihan == "1":
            if not Path(db.NAMA_DB).exists():
                print(f"Berkas {db.NAMA_DB} belum ada — jalankan main.py dulu.")
                continue
            tujuan = input("Nama berkas backup: ").strip()
            if not tujuan:
                print("Nama berkas tidak boleh kosong.")
                continue
            try:
                buat_backup(db.NAMA_DB, tujuan)
            except sqlite3.DatabaseError as e:
                print(f"Backup gagal: {e}")
                continue
            print(f"Backup {db.NAMA_DB} tersimpan ke {tujuan}.")
            print("Verifikasi integritas: ok")
        elif pilihan == "2":
            sumber = input("Nama berkas backup: ").strip()
            if not sumber:
                print("Nama berkas tidak boleh kosong.")
                continue
            if not Path(sumber).exists():
                print(f"Berkas {sumber} tidak ditemukan.")
                continue
            try:
                restore_backup(sumber, db.NAMA_DB)
            except sqlite3.DatabaseError as e:
                print(f"Restore gagal: {e}")
                continue
            print(f"Database {db.NAMA_DB} dipulihkan dari {sumber}.")
            print("Verifikasi integritas: ok")
        elif pilihan == "3":
            print("Sampai jumpa!")
            break
        else:
            print("Pilihan tidak dikenal.")


if __name__ == "__main__":
    main()

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

laporan.py

Unduh berkas
python
"""Laporan bulanan untuk Pencatat Keuangan Pribadi."""

from contextlib import closing

import db


def ringkasan_bulanan(bulan, path=db.NAMA_DB):
    """Totalkan pemasukan, pengeluaran, dan saldo satu bulan (format YYYY-MM)."""
    with closing(db.get_connection(path)) as con:
        return con.execute(
            "SELECT"
            " COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah END), 0) AS total_masuk,"
            " COALESCE(SUM(CASE WHEN jenis = 'keluar' THEN jumlah END), 0) AS total_keluar,"
            " COALESCE(SUM(CASE WHEN jenis = 'masuk' THEN jumlah END), 0)"
            " - COALESCE(SUM(CASE WHEN jenis = 'keluar' THEN jumlah END), 0) AS saldo"
            " FROM transaksi WHERE strftime('%Y-%m', tanggal) = ?",
            (bulan,),
        ).fetchone()


def kategori_terbesar(bulan, batas=3, path=db.NAMA_DB):
    """Ambil kategori pengeluaran terbesar pada satu bulan."""
    with closing(db.get_connection(path)) as con:
        return con.execute(
            "SELECT kategori, COUNT(*) AS jumlah_transaksi, SUM(jumlah) AS total"
            " FROM transaksi"
            " WHERE jenis = 'keluar' AND strftime('%Y-%m', tanggal) = ?"
            " GROUP BY kategori"
            " ORDER BY total DESC, kategori"
            " LIMIT ?",
            (bulan, batas),
        ).fetchall()


def rincian_per_kategori(bulan, path=db.NAMA_DB):
    """Hitung jumlah transaksi serta total masuk dan keluar tiap kategori."""
    with closing(db.get_connection(path)) as con:
        return con.execute(
            "SELECT kategori, COUNT(*) AS jumlah_transaksi,"
            " SUM(CASE WHEN jenis = 'masuk' THEN jumlah ELSE 0 END) AS total_masuk,"
            " SUM(CASE WHEN jenis = 'keluar' THEN jumlah ELSE 0 END) AS total_keluar"
            " FROM transaksi"
            " WHERE strftime('%Y-%m', tanggal) = ?"
            " GROUP BY kategori"
            " ORDER BY total_keluar DESC, kategori",
            (bulan,),
        ).fetchall()


def tampilkan_laporan(bulan, path=db.NAMA_DB):
    """Cetak laporan lengkap satu bulan ke layar."""
    ringkasan = ringkasan_bulanan(bulan, path)
    if ringkasan["total_masuk"] == 0 and ringkasan["total_keluar"] == 0:
        print(f"Tidak ada transaksi pada bulan {bulan}.")
        return

    print(f"=== Laporan Bulan {bulan} ===")
    print(f"Total pemasukan  : {db.format_rupiah(ringkasan['total_masuk'])}")
    print(f"Total pengeluaran: {db.format_rupiah(ringkasan['total_keluar'])}")
    print(f"Saldo            : {db.format_rupiah(ringkasan['saldo'])}")

    print()
    print("Pengeluaran terbesar:")
    for nomor, baris in enumerate(kategori_terbesar(bulan, path=path), start=1):
        print(
            f"{nomor}. {baris['kategori']:<13}"
            f" {db.format_rupiah(baris['total'])} ({baris['jumlah_transaksi']} transaksi)"
        )

    print()
    print("Rincian per kategori:")
    for baris in rincian_per_kategori(bulan, path=path):
        if baris["total_masuk"] > 0:
            print(
                f"{baris['kategori']:<14} {baris['jumlah_transaksi']:>2} transaksi"
                f"  masuk   {db.format_rupiah(baris['total_masuk'])}"
            )
        if baris["total_keluar"] > 0:
            print(
                f"{baris['kategori']:<14} {baris['jumlah_transaksi']:>2} transaksi"
                f"  keluar  {db.format_rupiah(baris['total_keluar'])}"
            )


def main():
    db.init_db()
    bulan = input("Bulan laporan (YYYY-MM): ").strip()
    tampilkan_laporan(bulan)


if __name__ == "__main__":
    main()

main.py

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

import sqlite3
from datetime import date

import db
import laporan


def tampilkan_menu():
    print("=== Pencatat Keuangan ===")
    print("1. Catat pemasukan")
    print("2. Catat pengeluaran")
    print("3. Lihat transaksi terakhir")
    print("4. Laporan bulanan")
    print("5. 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":
            bulan = input("Bulan laporan (YYYY-MM): ").strip()
            laporan.tampilkan_laporan(bulan)
        elif pilihan == "5":
            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 ''
);

test_pencatat.py

Unduh berkas
python
"""Test untuk Pencatat Keuangan Pribadi."""

import sqlite3

import pytest

import backup
import db
import laporan


@pytest.fixture
def path_db(tmp_path):
    """Siapkan database bersih dengan skema sudah dibuat."""
    path = tmp_path / "keuangan.db"
    db.init_db(path)
    return path


def isi_contoh(path):
    """Isi database dengan transaksi tetap dari dua bulan."""
    data = [
        ("2025-06-01", "masuk", "Gaji", 5500000, "Gaji bulanan"),
        ("2025-06-03", "keluar", "Makan", 45000, "Makan siang"),
        ("2025-06-05", "keluar", "Transportasi", 25000, "Ojek ke kantor"),
        ("2025-06-20", "keluar", "Makan", 38000, "Makan malam"),
        ("2025-07-01", "masuk", "Gaji", 5500000, "Gaji bulanan"),
        ("2025-07-02", "keluar", "Hiburan", 50000, "Tiket bioskop"),
    ]
    for baris in data:
        db.insert_transaksi(*baris, path=path)


def test_init_db_bisa_dipanggil_berulang(tmp_path):
    path = tmp_path / "keuangan.db"
    db.init_db(path)
    db.init_db(path)

    con = db.get_connection(path)
    jumlah_tabel = con.execute(
        "SELECT COUNT(*) FROM sqlite_master WHERE name = 'transaksi'"
    ).fetchone()[0]
    con.close()
    assert jumlah_tabel == 1


def test_insert_dan_baca_bolak_balik(path_db):
    id_baru = db.insert_transaksi(
        "2025-06-01", "masuk", "Gaji", 5500000, "Gaji bulanan", path=path_db
    )

    hasil = db.transaksi_terakhir(path=path_db)
    assert len(hasil) == 1
    baris = hasil[0]
    assert baris["id"] == id_baru
    assert baris["tanggal"] == "2025-06-01"
    assert baris["jenis"] == "masuk"
    assert baris["kategori"] == "Gaji"
    assert baris["jumlah"] == 5500000
    assert baris["catatan"] == "Gaji bulanan"


def test_transaksi_terakhir_urutan_dan_batas(path_db):
    isi_contoh(path_db)

    hasil = db.transaksi_terakhir(batas=4, path=path_db)

    assert [t["id"] for t in hasil] == [6, 5, 4, 3]


def test_jumlah_harus_positif(path_db):
    for jumlah_buruk in (0, -5000):
        with pytest.raises(sqlite3.IntegrityError):
            db.insert_transaksi("2025-06-01", "masuk", "Gaji", jumlah_buruk, path=path_db)


def test_jenis_harus_masuk_atau_keluar(path_db):
    with pytest.raises(sqlite3.IntegrityError):
        db.insert_transaksi("2025-06-01", "transfer", "Lain-lain", 10000, path=path_db)


def test_tanggal_harus_format_iso(path_db):
    with pytest.raises(sqlite3.IntegrityError):
        db.insert_transaksi("15-06-2025", "masuk", "Gaji", 5500000, path=path_db)


def test_ringkasan_bulanan(path_db):
    isi_contoh(path_db)

    ringkasan = laporan.ringkasan_bulanan("2025-06", path=path_db)

    assert ringkasan["total_masuk"] == 5500000
    assert ringkasan["total_keluar"] == 108000
    assert ringkasan["saldo"] == 5392000


def test_ringkasan_bulan_tanpa_transaksi(path_db):
    ringkasan = laporan.ringkasan_bulanan("2025-12", path=path_db)

    assert ringkasan["total_masuk"] == 0
    assert ringkasan["total_keluar"] == 0
    assert ringkasan["saldo"] == 0


def test_kategori_terbesar(path_db):
    isi_contoh(path_db)

    hasil = laporan.kategori_terbesar("2025-06", path=path_db)
    assert [(r["kategori"], r["jumlah_transaksi"], r["total"]) for r in hasil] == [
        ("Makan", 2, 83000),
        ("Transportasi", 1, 25000),
    ]

    satu = laporan.kategori_terbesar("2025-06", batas=1, path=path_db)
    assert [r["kategori"] for r in satu] == ["Makan"]


def test_backup_dan_restore(tmp_path):
    path_db = tmp_path / "keuangan.db"
    db.init_db(path_db)
    isi_contoh(path_db)
    path_backup = tmp_path / "keuangan-salinan.db"

    backup.buat_backup(path_db, path_backup)
    assert backup.verifikasi_integritas(path_backup)

    db.insert_transaksi("2025-07-10", "keluar", "Makan", 30000, path=path_db)
    assert len(db.transaksi_terakhir(path=path_db)) == 7

    backup.restore_backup(path_backup, path_db)
    assert len(db.transaksi_terakhir(path=path_db)) == 6
    assert backup.verifikasi_integritas(path_db)


def test_restore_berkas_rusak(tmp_path):
    path_rusak = tmp_path / "rusak.db"
    path_rusak.write_text("ini bukan berkas database", encoding="utf-8")

    with pytest.raises(sqlite3.DatabaseError):
        backup.restore_backup(path_rusak, tmp_path / "tujuan.db")