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:

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

  1. CASE WHEN jenis = 'masuk' THEN jumlah END: ekspresi CASE tanpa cabang ELSE mengembalikan NULL untuk baris pengeluaran — dan SUM() memang mengabaikan NULL (Chapter Ekspresi CASE). Hasilnya, SUM hanya menjumlahkan kolom jumlah untuk jenis yang cocok.
  2. COALESCE(..., 0): bulan tanpa transaksi membuat SUM menghasilkan NULL; COALESCE menggantinya dengan 0 sehingga laporan bulan kosong tetap berupa angka, bukan NULL.
  3. strftime('%Y-%m', tanggal) = ?: fungsi tanggal pada Chapter Bekerja dengan Tanggal dan Waktu memotong teks ISO menjadi YYYY-MM, sehingga satu parameter 2025-06 menyaring seluruh bulan itu. Nilai bulan tetap mengalir lewat placeholder ?.
  4. 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:

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

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

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

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:

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

bash
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.py

Output yang Dihasilkan:

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

bash
echo "2025-08" | python3 laporan.py

Output yang Dihasilkan:

text
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
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 ''
);