Transaksi kini terkumpul di keuangan.db, dan pertanyaan yang sebenarnya penting mulai muncul: berapa total pengeluaran bulan ini, dan ke kategori mana uang paling banyak mengalir? Kita bisa menjawabnya dengan loop Python yang membaca setiap baris lalu menjumlahkannya satu per satu — tetapi pekerjaan menjumlahkan dan mengelompokkan memang milik engine SQLite. Pada bagian ini kita menyusun laporan bulanan dari tiga query agregat, membungkusnya dalam modul laporan.py, lalu mengaitkannya ke menu utama.
Tiga Query Inti #
Seluruh laporan berdiri di atas tiga query. Yang pertama merangkum satu bulan menjadi tiga angka — total pemasukan, total pengeluaran, dan saldo:
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) = ?Mari kita bedah cara kerjanya:
CASE WHEN jenis = 'masuk' THEN jumlah END: ekspresi CASE tanpa cabangELSEmengembalikanNULLuntuk baris pengeluaran — danSUM()memang mengabaikanNULL(Chapter Ekspresi CASE). Hasilnya,SUMhanya menjumlahkan kolomjumlahuntuk jenis yang cocok.COALESCE(..., 0): bulan tanpa transaksi membuatSUMmenghasilkanNULL;COALESCEmenggantinya dengan0sehingga laporan bulan kosong tetap berupa angka, bukanNULL.strftime('%Y-%m', tanggal) = ?: fungsi tanggal pada Chapter Bekerja dengan Tanggal dan Waktu memotong teks ISO menjadiYYYY-MM, sehingga satu parameter2025-06menyaring seluruh bulan itu. Nilai bulan tetap mengalir lewat placeholder?.- Kolom
saldo: pengurangan dua total pertama dihitung di dalam query, jadi laporan lengkap tiba sebagai satu baris siap pakai.
Dua query berikutnya memilah pengeluaran per kategori. Query kedua mengurutkan kategori pengeluaran terbesar dan membatasinya, query ketiga merinci seluruh kategori beserta jumlah transaksinya:
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 ?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, kategoriDua kebiasaan kecil di sini penting. ORDER BY total DESC, kategori menambah kunci kedua sehingga kategori dengan total sama selalu ditata alfabetis — urutan hasil jadi pasti. Sedangkan ELSE 0 pada query rincian membuat SUM menerima angka untuk setiap baris alih-alih NULL, dan COUNT(*) menghitung seluruh transaksi kategori tersebut tanpa memandang jenisnya.
laporan.py #
Ketiga query tersebut dibungkus fungsi yang mengembalikan data — bukan yang mencetak — sehingga Bagian 4 bisa mengujinya tanpa membaca layar. Berikut isi lengkap laporan.py:
"""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()Perhatikan pembagian kerjanya. Fungsi ringkasan_bulanan(), kategori_terbesar(), dan rincian_per_kategori() membuka koneksi lewat db.get_connection() — kebijakan koneksi tetap terpusat di modul db.py — lalu mengembalikan sqlite3.Row hasil query. Fungsi tampilkan_laporan() menjadi satu-satunya yang mencetak: ia memeriksa kasus bulan kosong lewat dua total nol, mencetak ringkasan, lalu mengulang dua daftar kategori dengan enumerate() untuk penomoran. Fungsi main() memakai db.init_db() juga, sehingga laporan.py tetap aman dijalankan di folder sebelum main.py pernah dijalankan.
Menyambungkan ke Menu Utama #
main.py cukup mendapat satu pilihan baru: menu bertuliskan 4. Laporan bulanan, pilihan 5. Keluar, baris import laporan di bagian atas, serta satu cabang baru di tengah rantai dispatch:
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.")Dengan tambahan itu, program utama siap dipakai. Sesi berikut memulai dari berkas kosong, mencatat empat transaksi bulan Juni — satu gaji dan tiga pengeluaran — lalu mencetak laporan dari dalam menu:
rm -f keuangan.db
echo "1
2025-06-01
Gaji
5500000
Gaji bulanan
2
2025-06-03
Makan
45000
Makan siang
2
2025-06-05
Transportasi
25000
Ojek ke kantor
2
2025-06-20
Makan
38000
Makan malam
3
4
2025-06
5" | python3 main.pyOutput yang Dihasilkan:
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Laporan bulanan
5. 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. Laporan bulanan
5. Keluar
Pilihan: --- Catat Pengeluaran ---
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. Laporan bulanan
5. Keluar
Pilihan: --- Catat Pengeluaran ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Catatan (opsional): Transaksi tersimpan dengan id 3.
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Laporan bulanan
5. Keluar
Pilihan: --- Catat Pengeluaran ---
Tanggal (YYYY-MM-DD, Enter = hari ini): Kategori: Jumlah (Rp): Catatan (opsional): Transaksi tersimpan dengan id 4.
=== 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: Bulan laporan (YYYY-MM): === Laporan Bulan 2025-06 ===
Total pemasukan : Rp5.500.000
Total pengeluaran: Rp108.000
Saldo : Rp5.392.000
Pengeluaran terbesar:
1. Makan Rp83.000 (2 transaksi)
2. Transportasi Rp25.000 (1 transaksi)
Rincian per kategori:
Makan 2 transaksi keluar Rp83.000
Transportasi 1 transaksi keluar Rp25.000
Gaji 1 transaksi masuk Rp5.500.000
=== Pencatat Keuangan ===
1. Catat pemasukan
2. Catat pengeluaran
3. Lihat transaksi terakhir
4. Laporan bulanan
5. Keluar
Pilihan: Sampai jumpa!Modul laporan.py juga bisa berdiri sendiri sebagai program sekali jalan — berguna saat laporan ingin dilihat tanpa membuka menu:
echo "2025-08" | python3 laporan.pyOutput yang Dihasilkan:
Bulan laporan (YYYY-MM): Tidak ada transaksi pada bulan 2025-08.Laporan bulanan kini menjawab pertanyaan yang tadinya butuh kalkulator dan kertas. Seluruh berkas tahap ini tersedia pada bagian Berkas Sumber di bawah halaman. Bagian 4 menutup proyek dengan dua hal yang membedakan mainan dari alat kerja: test otomatis dan backup.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
db.py
Unduh berkas"""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"""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"""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-- 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 ''
);