Sembilan belas transaksi selama tiga bulan masih sanggup dihitung dengan loop; catatan yang bertambah setiap hari selama bertahun-tahun akan mengubur logika di dalam indeks dan penanda. Bagian 3 memindahkan seluruh perhitungan laporan ke Stream API: ringkasan per bulan dengan groupingBy dan teeing, pengeluaran per kategori dengan reducing, pembagian nominal besar-kecil dengan partitioningBy, serta tren antar bulan berurutan. Analisis kini menempati kelasnya sendiri bernama AnalisisKeuangan, terpisah dari BukuKas yang berurusan penyimpanan dan validasi. Prinsipnya sederhana: data menjaga dirinya sendiri, analisis hanya membaca, sehingga keduanya boleh berkembang tanpa saling menyeret.

Hasil Analisis sebagai Record #

Satu baris ringkasan bulanan adalah nilai data utuh, jadi hasil analisis juga berhak atas record. LaporanBulanan menyimpan dua total dan menghitung selisih sebagai method turunan, bukan field — angka turunan tidak disimpan agar tidak pernah basi:

import java.math.BigDecimal;

/** Ringkasan satu bulan: total pemasukan dan pengeluaran; selisih dihitung langsung. */
public record LaporanBulanan(BigDecimal totalPemasukan, BigDecimal totalPengeluaran) {

    /** Surplus bila positif, defisit bila negatif. */
    public BigDecimal selisih() {
        return totalPemasukan.subtract(totalPengeluaran);
    }
}

Record ini sengaja tanpa field bulan: nilai LaporanBulanan tinggal di Map<YearMonth, LaporanBulanan> dan bulan sudah terbawa kunci peta; menggandakannya sebagai field hanya membuka peluang dua sumber bertentangan.

Kumpulan Operasi Stream dalam AnalisisKeuangan #

Kelas AnalisisKeuangan merangkum empat operasi analisis sebagai method static, masing-masing satu pipeline utuh yang bisa dipakai ulang:

import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collector;
import java.util.stream.Collectors;

/** Kumpulan operasi analisis berbasis Stream API untuk daftar transaksi. */
public final class AnalisisKeuangan {

    private AnalisisKeuangan() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Collector penjumlah nominal — padanan summingInt untuk uang BigDecimal. */
    public static Collector<Transaksi, ?, BigDecimal> penjumlahan() {
        return Collectors.reducing(BigDecimal.ZERO, Transaksi::jumlah, BigDecimal::add);
    }

    /** Laporan lengkap per bulan: groupingBy berjenjang dengan teeing + filtering. */
    public static Map<YearMonth, LaporanBulanan> laporanPerBulan(List<Transaksi> transaksi) {
        return transaksi.stream()
                .collect(Collectors.groupingBy(
                        Transaksi::bulan,
                        TreeMap::new,
                        Collectors.teeing(
                                Collectors.filtering(
                                        t -> t.jenis() == JenisTransaksi.PEMASUKAN, penjumlahan()),
                                Collectors.filtering(
                                        t -> t.jenis() == JenisTransaksi.PENGELUARAN, penjumlahan()),
                                LaporanBulanan::new)));
    }

    /** Total pengeluaran per kategori pada satu bulan: groupingBy + reducing. */
    public static Map<String, BigDecimal> pengeluaranPerKategori(List<Transaksi> transaksi,
                                                                 YearMonth bulan) {
        return transaksi.stream()
                .filter(t -> t.jenis() == JenisTransaksi.PENGELUARAN)
                .filter(t -> t.bulan().equals(bulan))
                .collect(Collectors.groupingBy(Transaksi::kategori, penjumlahan()));
    }

    /** Memecah pengeluaran satu bulan menjadi dua kelompok: di atas dan di bawah ambang. */
    public static Map<Boolean, List<Transaksi>> partisiNominal(List<Transaksi> transaksi,
                                                               YearMonth bulan,
                                                               BigDecimal ambang) {
        return transaksi.stream()
                .filter(t -> t.jenis() == JenisTransaksi.PENGELUARAN)
                .filter(t -> t.bulan().equals(bulan))
                .collect(Collectors.partitioningBy(
                        t -> t.jumlah().compareTo(ambang) > 0));
    }
}

Mari kita bedah bagian-bagian kuncinya:

  1. penjumlahan(): Collectors.reducing(BigDecimal.ZERO, Transaksi::jumlah, BigDecimal::add) menyusun Collector penjumlahan — class Collectors tidak menyediakan summingBigDecimal, jadi reducing menjadi cara baku menjumlahkan uang; identitas ZERO membuat stream kosong tetap menghasilkan 0.
  2. laporanPerBulan: groupingBy(Transaksi::bulan, TreeMap::new, downstream) membagi transaksi per bulan dengan TreeMap sebagai wadah sehingga iterasi hasil terurut kronologis. Downstream-nya teeing yang menjalankan dua collector sekaligus terhadap anggota kelompok yang sama lalu menggabungkan keduanya lewat constructor LaporanBulanan::new.
  3. Cabang filtering: tiap cabang teeing menyaring anggotanya sendiri — pemasukan untuk cabang pertama, pengeluaran untuk cabang kedua. Satu operasi filter sebelum collect tidak mungkin membedakan cabang; Collectors.filtering bekerja di dalam tiap cabang sehingga kebutuhan ini terpenuhi.
  4. pengeluaranPerKategori: dua filter memangkas stream ke bulan dan jenis yang diinginkan, lalu groupingBy(Transaksi::kategori, penjumlahan()) menghasilkan Map<String, BigDecimal> berisi total per kategori.
  5. partisiNominal: partitioningBy menerima satu Predicate dan selalu menghasilkan kedua kunci true dan false — kelompok kosong tetap hadir dengan daftar kosong, bukan hilang.

Peta ringkas collector yang dipakai Bagian 3:

CollectorHasilPeran pada laporan
groupingBy(kunci, downstream)Map<K, D>pengelompokan per bulan / per kategori
reducing(nol, getter, op)nilai akumulasipenjumlahan nominal BigDecimal
filtering(predikat, downstream)downstream tersaringpemilihan cabang di dalam teeing
teeing(c1, c2, penggabung)gabungan dua hasilpemasukan + pengeluaran → laporan
partitioningBy(predikat)Map<Boolean, List<T>>nominal di atas versus di bawah ambang

Data Contoh dan Pintu Masuk #

Data kini cukup besar untuk dipisahkan dari kode demo: kelas DemoData memuat pendaftaran kategori dan sembilan belas transaksi kuartal pertama 2026, menyelamatkan DemoKeuangan dari kebisingan.

java DemoData.java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/** Data contoh Budi kuartal pertama 2026 — dipakai Bagian 3 dan 4. */
public final class DemoData {

    private DemoData() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Mendaftarkan enam kategori standar ke satu BukuKas. */
    public static void daftarkanKategori(BukuKas kas) {
        kas.daftarkanKategori(new Kategori("Gaji", JenisTransaksi.PEMASUKAN, null));
        kas.daftarkanKategori(new Kategori("Freelance", JenisTransaksi.PEMASUKAN, null));
        kas.daftarkanKategori(new Kategori("Sewa", JenisTransaksi.PENGELUARAN,
                new BigDecimal("2500000.00")));
        kas.daftarkanKategori(new Kategori("Makanan", JenisTransaksi.PENGELUARAN,
                new BigDecimal("1500000.00")));
        kas.daftarkanKategori(new Kategori("Transportasi", JenisTransaksi.PENGELUARAN,
                new BigDecimal("600000.00")));
        kas.daftarkanKategori(new Kategori("Hiburan", JenisTransaksi.PENGELUARAN,
                new BigDecimal("500000.00")));
    }

    /** Sembilan belas transaksi Januari sampai Maret 2026, urut menurut tanggal. */
    public static List<Transaksi> contohTransaksi() {
        return List.of(
                new Transaksi(LocalDate.of(2026, 1, 5), "Gaji bulanan Januari",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 1, 7), "Sewa tempat tinggal Januari",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 1, 10), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")),
                new Transaksi(LocalDate.of(2026, 1, 18), "Makan di luar bersama Siti",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("380000.00")),
                new Transaksi(LocalDate.of(2026, 1, 22), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("350000.00")),
                new Transaksi(LocalDate.of(2026, 1, 28), "Langganan streaming",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("250000.00")),
                new Transaksi(LocalDate.of(2026, 2, 5), "Gaji bulanan Februari",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 2, 7), "Sewa tempat tinggal Februari",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 2, 12), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("770000.00")),
                new Transaksi(LocalDate.of(2026, 2, 14), "Proyek desain lepas",
                        JenisTransaksi.PEMASUKAN, "Freelance", new BigDecimal("1200000.00")),
                new Transaksi(LocalDate.of(2026, 2, 18), "Makan di luar bersama Siti",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("520000.00")),
                new Transaksi(LocalDate.of(2026, 2, 19), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("280000.00")),
                new Transaksi(LocalDate.of(2026, 2, 25), "Tiket konser musik",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("620000.00")),
                new Transaksi(LocalDate.of(2026, 3, 5), "Gaji bulanan Maret",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 3, 6), "Sewa tempat tinggal Maret",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 3, 11), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("610000.00")),
                new Transaksi(LocalDate.of(2026, 3, 14), "Makan siang di luar",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("240000.00")),
                new Transaksi(LocalDate.of(2026, 3, 20), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("310000.00")),
                new Transaksi(LocalDate.of(2026, 3, 27), "Tiket bioskop",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("180000.00")));
    }
}

Pintu masuk Bagian 3 kemudian tinggal memanggil operasi analisis satu per satu:

import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/** Bagian 3: ringkasan bulanan, per kategori, partisi nominal, dan tren antar bulan. */
public class DemoKeuangan {

    public static void main(String[] args) throws KategoriTidakTerdahtarException {
        BukuKas kas = new BukuKas();
        DemoData.daftarkanKategori(kas);
        for (Transaksi t : DemoData.contohTransaksi()) {
            kas.tambah(t);
        }
        List<Transaksi> semua = kas.semua();

        // 1) Ringkasan per bulan: groupingBy + teeing + filtering.
        Map<YearMonth, LaporanBulanan> perBulan = AnalisisKeuangan.laporanPerBulan(semua);
        System.out.println("=== Ringkasan per bulan ===");
        for (Map.Entry<YearMonth, LaporanBulanan> e : perBulan.entrySet()) {
            LaporanBulanan l = e.getValue();
            System.out.printf(Locale.US, "%s  masuk %,13.2f  keluar %,13.2f  selisih %,13.2f%n",
                    e.getKey(), l.totalPemasukan(), l.totalPengeluaran(), l.selisih());
        }

        // 2) Tren selisih antar bulan berurutan.
        System.out.println();
        System.out.println("=== Tren selisih antar bulan ===");
        List<Map.Entry<YearMonth, LaporanBulanan>> urut = List.copyOf(perBulan.entrySet());
        for (int i = 1; i < urut.size(); i++) {
            BigDecimal sebelum = urut.get(i - 1).getValue().selisih();
            BigDecimal kini = urut.get(i).getValue().selisih();
            BigDecimal delta = kini.subtract(sebelum);
            String arah = delta.signum() >= 0 ? "naik" : "turun";
            System.out.printf(Locale.US, "%s -> %s : %-5s %,13.2f%n",
                    urut.get(i - 1).getKey(), urut.get(i).getKey(), arah, delta.abs());
        }

        // 3) Pengeluaran per kategori pada Februari, terurut dari yang terbesar.
        System.out.println();
        System.out.println("=== Pengeluaran per kategori — Februari 2026 ===");
        Map<String, BigDecimal> perKategori =
                AnalisisKeuangan.pengeluaranPerKategori(semua, YearMonth.of(2026, 2));
        perKategori.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue(Comparator.reverseOrder())
                        .thenComparing(Map.Entry.comparingByKey()))
                .forEach(e -> System.out.printf(Locale.US, "%-13s %,13.2f%n",
                        e.getKey(), e.getValue()));

        // 4) Partisi nominal: pengeluaran di atas versus di bawah ambang.
        System.out.println();
        BigDecimal ambang = new BigDecimal("500000.00");
        System.out.printf(Locale.US, "=== Partisi nominal Februari (ambang %,.2f) ===%n", ambang);
        Map<Boolean, List<Transaksi>> partisi =
                AnalisisKeuangan.partisiNominal(semua, YearMonth.of(2026, 2), ambang);
        System.out.println("Di atas ambang : " + partisi.get(true).size() + " transaksi");
        for (Transaksi t : partisi.get(true)) {
            System.out.printf(Locale.US, "  %s  %-13s %,13.2f  %s%n",
                    t.tanggal(), t.kategori(), t.jumlah(), t.keterangan());
        }
        System.out.println("Di bawah ambang: " + partisi.get(false).size() + " transaksi");
        for (Transaksi t : partisi.get(false)) {
            System.out.printf(Locale.US, "  %s  %-13s %,13.2f  %s%n",
                    t.tanggal(), t.kategori(), t.jumlah(), t.keterangan());
        }
    }
}

Output yang Dihasilkan:

text
=== Ringkasan per bulan ===
2026-01  masuk  8,500,000.00  keluar  3,930,000.00  selisih  4,570,000.00
2026-02  masuk  9,700,000.00  keluar  4,690,000.00  selisih  5,010,000.00
2026-03  masuk  8,500,000.00  keluar  3,840,000.00  selisih  4,660,000.00

=== Tren selisih antar bulan ===
2026-01 -> 2026-02 : naik     440,000.00
2026-02 -> 2026-03 : turun    350,000.00

=== Pengeluaran per kategori — Februari 2026 ===
Sewa           2,500,000.00
Makanan        1,290,000.00
Hiburan          620,000.00
Transportasi     280,000.00

=== Partisi nominal Februari (ambang 500,000.00) ===
Di atas ambang : 4 transaksi
  2026-02-07  Sewa           2,500,000.00  Sewa tempat tinggal Februari
  2026-02-12  Makanan          770,000.00  Belanja bahan makanan
  2026-02-18  Makanan          520,000.00  Makan di luar bersama Siti
  2026-02-25  Hiburan          620,000.00  Tiket konser musik
Di bawah ambang: 1 transaksi
  2026-02-19  Transportasi     280,000.00  Isi bensin

Lima pengamatan dari keluaran tersebut:

  1. Ringkasan tampil kronologis: TreeMap pada laporanPerBulan menjamin Januari, Februari, Maret berurutan; Februari unggul karena proyek lepas 1,200,000.00 menaikkan pemasukan menjadi 9,700,000.00.
  2. Tren antar bulan ditulis loop biasa: operasi membandingkan elemen ke i-1 dan i memang paling jelas diekspresikan dengan for; Stream unggul mengolah tiap elemen secara mandiri, bukan memasangkan tetangga. delta.signum() menentukan arah, delta.abs() mencetak besaran tanpa tanda minus ganda.
  3. Pengurutan entri peta: comparingByValue(Comparator.reverseOrder()) menempatkan Sewa 2,500,000.00 di puncak, lalu thenComparing(Map.Entry.comparingByKey()) menjadi pemutus seri bila dua kategori kebetulan sama besar — keluaran tetap deterministik.
  4. Partisi dua kelompok: empat transaksi melewati ambang 500,000.00 dan satu transaksi di bawahnya; partisi.get(false) aman diakses tanpa pemeriksaan null karena partitioningBy selalu menyediakan kedua kunci.
  5. Kolom rata kanan: format %,13.2f dengan Locale.US membuat nominal sejajar kanan sehingga besarannya terbanding sekilas.

Laporan kini hidup sebagai data: satu record per bulan, peta per kategori, partisi nominal. Satu hal terakhir yang belum dipegang aplikasi ini adalah kepastian bahwa data bertahan setelah program berhenti dan tetap utuh saat dibuka kembali. Bagian 4 menutup proyek dengan penyimpanan berkas terformat dan validasi integritas berlapis.

Referensi #

Berkas Sumber

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

AnalisisKeuangan.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collector;
import java.util.stream.Collectors;

/** Kumpulan operasi analisis berbasis Stream API untuk daftar transaksi. */
public final class AnalisisKeuangan {

    private AnalisisKeuangan() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Collector penjumlah nominal — padanan summingInt untuk uang BigDecimal. */
    public static Collector<Transaksi, ?, BigDecimal> penjumlahan() {
        return Collectors.reducing(BigDecimal.ZERO, Transaksi::jumlah, BigDecimal::add);
    }

    /** Laporan lengkap per bulan: groupingBy berjenjang dengan teeing + filtering. */
    public static Map<YearMonth, LaporanBulanan> laporanPerBulan(List<Transaksi> transaksi) {
        return transaksi.stream()
                .collect(Collectors.groupingBy(
                        Transaksi::bulan,
                        TreeMap::new,
                        Collectors.teeing(
                                Collectors.filtering(
                                        t -> t.jenis() == JenisTransaksi.PEMASUKAN, penjumlahan()),
                                Collectors.filtering(
                                        t -> t.jenis() == JenisTransaksi.PENGELUARAN, penjumlahan()),
                                LaporanBulanan::new)));
    }

    /** Total pengeluaran per kategori pada satu bulan: groupingBy + reducing. */
    public static Map<String, BigDecimal> pengeluaranPerKategori(List<Transaksi> transaksi,
                                                                 YearMonth bulan) {
        return transaksi.stream()
                .filter(t -> t.jenis() == JenisTransaksi.PENGELUARAN)
                .filter(t -> t.bulan().equals(bulan))
                .collect(Collectors.groupingBy(Transaksi::kategori, penjumlahan()));
    }

    /** Memecah pengeluaran satu bulan menjadi dua kelompok: di atas dan di bawah ambang. */
    public static Map<Boolean, List<Transaksi>> partisiNominal(List<Transaksi> transaksi,
                                                               YearMonth bulan,
                                                               BigDecimal ambang) {
        return transaksi.stream()
                .filter(t -> t.jenis() == JenisTransaksi.PENGELUARAN)
                .filter(t -> t.bulan().equals(bulan))
                .collect(Collectors.partitioningBy(
                        t -> t.jumlah().compareTo(ambang) > 0));
    }
}

BukuKas.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** Kumpulan transaksi satu pemilik, kini dengan kategori terdaftar dan anggaran bulanan. */
public class BukuKas {

    private final Map<String, Kategori> kategori = new LinkedHashMap<>();
    private final List<Transaksi> daftar = new ArrayList<>();

    /** Mendaftarkan kategori baru; nama yang sama tidak boleh didaftarkan dua kali. */
    public void daftarkanKategori(Kategori k) {
        if (k == null) {
            throw new IllegalArgumentException("Kategori tidak boleh null");
        }
        if (kategori.containsKey(k.nama())) {
            throw new IllegalStateException("Kategori '" + k.nama() + "' sudah terdaftar");
        }
        kategori.put(k.nama(), k);
    }

    public Kategori cariKategori(String nama) {
        return kategori.get(nama);
    }

    /** Menambah transaksi setelah kategorinya terbukti terdaftar dan cocok jenisnya. */
    public void tambah(Transaksi t) throws KategoriTidakTerdahtarException {
        Kategori k = kategori.get(t.kategori());
        if (k == null) {
            throw new KategoriTidakTerdahtarException(
                    "Kategori '" + t.kategori() + "' belum terdaftar di BukuKas");
        }
        if (k.jenis() != t.jenis()) {
            throw new IllegalArgumentException("Kategori '" + k.nama() + "' dikhususkan untuk "
                    + k.jenis() + ", transaksi ini berjenis " + t.jenis());
        }
        daftar.add(t);
    }

    /** Daftar transaksi baca-saja agar pemanggil tidak dapat mengubah catatan dari luar. */
    public List<Transaksi> semua() {
        return Collections.unmodifiableList(daftar);
    }

    public int jumlahTransaksi() {
        return daftar.size();
    }

    /** Menjumlahkan nominal seluruh transaksi satu jenis. */
    public BigDecimal total(JenisTransaksi jenis) {
        BigDecimal total = BigDecimal.ZERO;
        for (Transaksi t : daftar) {
            if (t.jenis() == jenis) {
                total = total.add(t.jumlah());
            }
        }
        return total;
    }

    /** Saldo = total pemasukan dikurangi total pengeluaran. */
    public BigDecimal saldo() {
        return total(JenisTransaksi.PEMASUKAN)
                .subtract(total(JenisTransaksi.PENGELUARAN));
    }

    /**
     * Realisasi pengeluaran per kategori pada satu bulan.
     * Seluruh kategori pengeluaran ikut hadir; kategori tanpa transaksi bernilai nol.
     */
    public Map<String, BigDecimal> realisasiPengeluaran(YearMonth bulan) {
        Map<String, BigDecimal> hasil = new LinkedHashMap<>();
        for (Kategori k : kategori.values()) {
            if (k.jenis() == JenisTransaksi.PENGELUARAN) {
                hasil.put(k.nama(), BigDecimal.ZERO);
            }
        }
        for (Transaksi t : daftar) {
            if (t.jenis() == JenisTransaksi.PENGELUARAN && t.bulan().equals(bulan)) {
                hasil.merge(t.kategori(), t.jumlah(), BigDecimal::add);
            }
        }
        return hasil;
    }

    /** Kategori pengeluaran beranggaran dalam urutan pendaftaran, untuk laporan anggaran. */
    public List<Kategori> kategoriBeranggaran() {
        List<Kategori> hasil = new ArrayList<>();
        for (Kategori k : kategori.values()) {
            if (k.beranggaran()) {
                hasil.add(k);
            }
        }
        return hasil;
    }
}

DemoData.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;

/** Data contoh Budi kuartal pertama 2026 — dipakai Bagian 3 dan 4. */
public final class DemoData {

    private DemoData() {
        // Kelas utilitas: seluruh anggota static, tidak perlu instance.
    }

    /** Mendaftarkan enam kategori standar ke satu BukuKas. */
    public static void daftarkanKategori(BukuKas kas) {
        kas.daftarkanKategori(new Kategori("Gaji", JenisTransaksi.PEMASUKAN, null));
        kas.daftarkanKategori(new Kategori("Freelance", JenisTransaksi.PEMASUKAN, null));
        kas.daftarkanKategori(new Kategori("Sewa", JenisTransaksi.PENGELUARAN,
                new BigDecimal("2500000.00")));
        kas.daftarkanKategori(new Kategori("Makanan", JenisTransaksi.PENGELUARAN,
                new BigDecimal("1500000.00")));
        kas.daftarkanKategori(new Kategori("Transportasi", JenisTransaksi.PENGELUARAN,
                new BigDecimal("600000.00")));
        kas.daftarkanKategori(new Kategori("Hiburan", JenisTransaksi.PENGELUARAN,
                new BigDecimal("500000.00")));
    }

    /** Sembilan belas transaksi Januari sampai Maret 2026, urut menurut tanggal. */
    public static List<Transaksi> contohTransaksi() {
        return List.of(
                new Transaksi(LocalDate.of(2026, 1, 5), "Gaji bulanan Januari",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 1, 7), "Sewa tempat tinggal Januari",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 1, 10), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")),
                new Transaksi(LocalDate.of(2026, 1, 18), "Makan di luar bersama Siti",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("380000.00")),
                new Transaksi(LocalDate.of(2026, 1, 22), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("350000.00")),
                new Transaksi(LocalDate.of(2026, 1, 28), "Langganan streaming",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("250000.00")),
                new Transaksi(LocalDate.of(2026, 2, 5), "Gaji bulanan Februari",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 2, 7), "Sewa tempat tinggal Februari",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 2, 12), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("770000.00")),
                new Transaksi(LocalDate.of(2026, 2, 14), "Proyek desain lepas",
                        JenisTransaksi.PEMASUKAN, "Freelance", new BigDecimal("1200000.00")),
                new Transaksi(LocalDate.of(2026, 2, 18), "Makan di luar bersama Siti",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("520000.00")),
                new Transaksi(LocalDate.of(2026, 2, 19), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("280000.00")),
                new Transaksi(LocalDate.of(2026, 2, 25), "Tiket konser musik",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("620000.00")),
                new Transaksi(LocalDate.of(2026, 3, 5), "Gaji bulanan Maret",
                        JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")),
                new Transaksi(LocalDate.of(2026, 3, 6), "Sewa tempat tinggal Maret",
                        JenisTransaksi.PENGELUARAN, "Sewa", new BigDecimal("2500000.00")),
                new Transaksi(LocalDate.of(2026, 3, 11), "Belanja bahan makanan",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("610000.00")),
                new Transaksi(LocalDate.of(2026, 3, 14), "Makan siang di luar",
                        JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("240000.00")),
                new Transaksi(LocalDate.of(2026, 3, 20), "Isi bensin",
                        JenisTransaksi.PENGELUARAN, "Transportasi", new BigDecimal("310000.00")),
                new Transaksi(LocalDate.of(2026, 3, 27), "Tiket bioskop",
                        JenisTransaksi.PENGELUARAN, "Hiburan", new BigDecimal("180000.00")));
    }
}

DemoKeuangan.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/** Bagian 3: ringkasan bulanan, per kategori, partisi nominal, dan tren antar bulan. */
public class DemoKeuangan {

    public static void main(String[] args) throws KategoriTidakTerdahtarException {
        BukuKas kas = new BukuKas();
        DemoData.daftarkanKategori(kas);
        for (Transaksi t : DemoData.contohTransaksi()) {
            kas.tambah(t);
        }
        List<Transaksi> semua = kas.semua();

        // 1) Ringkasan per bulan: groupingBy + teeing + filtering.
        Map<YearMonth, LaporanBulanan> perBulan = AnalisisKeuangan.laporanPerBulan(semua);
        System.out.println("=== Ringkasan per bulan ===");
        for (Map.Entry<YearMonth, LaporanBulanan> e : perBulan.entrySet()) {
            LaporanBulanan l = e.getValue();
            System.out.printf(Locale.US, "%s  masuk %,13.2f  keluar %,13.2f  selisih %,13.2f%n",
                    e.getKey(), l.totalPemasukan(), l.totalPengeluaran(), l.selisih());
        }

        // 2) Tren selisih antar bulan berurutan.
        System.out.println();
        System.out.println("=== Tren selisih antar bulan ===");
        List<Map.Entry<YearMonth, LaporanBulanan>> urut = List.copyOf(perBulan.entrySet());
        for (int i = 1; i < urut.size(); i++) {
            BigDecimal sebelum = urut.get(i - 1).getValue().selisih();
            BigDecimal kini = urut.get(i).getValue().selisih();
            BigDecimal delta = kini.subtract(sebelum);
            String arah = delta.signum() >= 0 ? "naik" : "turun";
            System.out.printf(Locale.US, "%s -> %s : %-5s %,13.2f%n",
                    urut.get(i - 1).getKey(), urut.get(i).getKey(), arah, delta.abs());
        }

        // 3) Pengeluaran per kategori pada Februari, terurut dari yang terbesar.
        System.out.println();
        System.out.println("=== Pengeluaran per kategori — Februari 2026 ===");
        Map<String, BigDecimal> perKategori =
                AnalisisKeuangan.pengeluaranPerKategori(semua, YearMonth.of(2026, 2));
        perKategori.entrySet().stream()
                .sorted(Map.Entry.<String, BigDecimal>comparingByValue(Comparator.reverseOrder())
                        .thenComparing(Map.Entry.comparingByKey()))
                .forEach(e -> System.out.printf(Locale.US, "%-13s %,13.2f%n",
                        e.getKey(), e.getValue()));

        // 4) Partisi nominal: pengeluaran di atas versus di bawah ambang.
        System.out.println();
        BigDecimal ambang = new BigDecimal("500000.00");
        System.out.printf(Locale.US, "=== Partisi nominal Februari (ambang %,.2f) ===%n", ambang);
        Map<Boolean, List<Transaksi>> partisi =
                AnalisisKeuangan.partisiNominal(semua, YearMonth.of(2026, 2), ambang);
        System.out.println("Di atas ambang : " + partisi.get(true).size() + " transaksi");
        for (Transaksi t : partisi.get(true)) {
            System.out.printf(Locale.US, "  %s  %-13s %,13.2f  %s%n",
                    t.tanggal(), t.kategori(), t.jumlah(), t.keterangan());
        }
        System.out.println("Di bawah ambang: " + partisi.get(false).size() + " transaksi");
        for (Transaksi t : partisi.get(false)) {
            System.out.printf(Locale.US, "  %s  %-13s %,13.2f  %s%n",
                    t.tanggal(), t.kategori(), t.jumlah(), t.keterangan());
        }
    }
}

JenisTransaksi.java

Unduh berkas
java
/** Jenis arus kas: uang masuk atau uang keluar. */
public enum JenisTransaksi {
    PEMASUKAN,
    PENGELUARAN
}

Kategori.java

Unduh berkas
java
import java.math.BigDecimal;

/**
 * Kategori transaksi beserta anggaran bulanan opsional.
 * Kategori pemasukan diberi anggaran null karena pemasukan tidak dibatasi plafon.
 */
public record Kategori(String nama, JenisTransaksi jenis, BigDecimal anggaranBulanan) {

    public Kategori {
        if (nama == null || nama.isBlank()) {
            throw new IllegalArgumentException("Nama kategori tidak boleh kosong");
        }
        if (jenis == null) {
            throw new IllegalArgumentException("Jenis kategori wajib terisi");
        }
        if (anggaranBulanan != null && anggaranBulanan.signum() < 0) {
            throw new IllegalArgumentException("Anggaran tidak boleh negatif");
        }
    }

    /** Apakah kategori ini membawa batas pengeluaran bulanan. */
    public boolean beranggaran() {
        return anggaranBulanan != null;
    }
}

KategoriTidakTerdahtarException.java

Unduh berkas
java
/** Sinyal bahwa transaksi memakai kategori yang belum didaftarkan ke BukuKas. */
public class KategoriTidakTerdahtarException extends Exception {

    public KategoriTidakTerdahtarException(String pesan) {
        super(pesan);
    }
}

LaporanBulanan.java

Unduh berkas
java
import java.math.BigDecimal;

/** Ringkasan satu bulan: total pemasukan dan pengeluaran; selisih dihitung langsung. */
public record LaporanBulanan(BigDecimal totalPemasukan, BigDecimal totalPengeluaran) {

    /** Surplus bila positif, defisit bila negatif. */
    public BigDecimal selisih() {
        return totalPemasukan.subtract(totalPengeluaran);
    }
}

Transaksi.java

Unduh berkas
java
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.YearMonth;

/**
 * Satu baris catatan keuangan: tanggal, keterangan, jenis, kategori, dan nominal.
 * Nominal selalu BigDecimal agar bebas galat pembulatan biner.
 */
public record Transaksi(LocalDate tanggal, String keterangan, JenisTransaksi jenis,
                        String kategori, BigDecimal jumlah) {

    // Compact constructor: validasi berjalan otomatis pada setiap pembuatan record.
    public Transaksi {
        if (tanggal == null || keterangan == null || jenis == null
                || kategori == null || jumlah == null) {
            throw new IllegalArgumentException("Seluruh field transaksi wajib terisi");
        }
        if (keterangan.isBlank()) {
            throw new IllegalArgumentException("Keterangan tidak boleh kosong");
        }
        if (kategori.isBlank()) {
            throw new IllegalArgumentException("Kategori tidak boleh kosong");
        }
        if (jumlah.signum() <= 0) {
            throw new IllegalArgumentException("Jumlah harus positif, diterima: "
                    + jumlah.toPlainString());
        }
    }

    /** Bulan terjadinya transaksi — kunci alami untuk laporan bulanan. */
    public YearMonth bulan() {
        return YearMonth.from(tanggal);
    }
}