Daftar transaksi yang tumbuh bebas cepat atau lambat menuntut tata kelola: teks kategori yang bebas membuat “Makanan” dan “makanan” terhitung sebagai dua kelompok berbeda, dan pengeluaran tanpa batas tidak pernah ditegur sampai saldo menipis. Bagian 2 membangun tata kelola tersebut dengan mendaftarkan kategori sebagai kontrak resmi di BukuKas, lengkap dengan jenis dan anggaran bulanan. Setiap transaksi baru harus lolos validasi silang — kategori terdaftar dan jenis cocok — sebelum menyentuh daftar transaksi. Pelanggaran dilaporkan lewat exception khusus KategoriTidakTerdahtarException, lalu realisasi tiap kategori dibandingkan dengan anggarannya pada akhir bulan.
Kategori sebagai Kontrak Data #
Kategori kini berhak atas model sendiri berbentuk record dengan tiga komponen: nama, jenis, dan anggaran bulanan opsional. Anggaran null pada kategori pemasukan bukan kelalaian, melainkan keputusan desain — pemasukan tidak dibatasi plafon, dan null tidak bisa tertukar dengan BigDecimal.ZERO yang akan terbaca sebagai anggaran nol rupiah:
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;
}
}Dua hal patut dicatat dari definisi tersebut. Compact constructor menjaga tiga invarian sekaligus — nama terisi, jenis jelas, anggaran tidak negatif — sehingga objek Kategori yang lolos pasti sah. Method tambahan beranggaran() mencontohkan bahwa record juga menerima method biasa; pemanggil tidak perlu mengulang pemeriksaan null di banyak tempat.
Penanda Bulan pada Transaksi #
Laporan anggaran dikelompokkan per bulan, jadi Transaksi beroleh satu method kenyamanan yang mengubah tanggal menjadi kunci YearMonth:
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);
}
}Method bulan() memanggil YearMonth.from(tanggal) untuk memotong tanggal menjadi pasangan tahun-bulan. Deklarasi komponen record tetap stabil — record bertumbuh dengan menambah method, bukan mengubah komponen.
Validasi Silang di BukuKas #
BukuKas kini menyimpan dua koleksi: LinkedHashMap kategori terdaftar dan daftar transaksi. Penyisipan ke LinkedHashMap mengingat urutan pendaftaran, sehingga laporan tampil sesuai urutan kategori didaftarkan — deterministik dan enak dibaca. Aturan penambahan transaksi menjadi dua lapis:
/** Sinyal bahwa transaksi memakai kategori yang belum didaftarkan ke BukuKas. */
public class KategoriTidakTerdahtarException extends Exception {
public KategoriTidakTerdahtarException(String pesan) {
super(pesan);
}
}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;
}
}Empat titik penting pada BukuKas versi baru:
daftarkanKategorimenolak nama ganda denganIllegalStateExceptionkarena definisi ganda akan membuat aturan anggaran menjadi kabur.tambahmemeriksa dua hal:kategori.get(...)yang menghasilkannullberarti kategori belum terdaftar (exception bertipe checked), sedangkan jenis yang tidak cocok adalah kesalahan pemrograman (exception unchecked).realisasiPengeluaranmengisi nilai nol lebih dulu untuk semua kategori pengeluaran, lalumergemenjumlahkan transaksi yang cocok; kategori sepi tetap tampil di tabel, bukan hilang diam-diam.kategoriBeranggaranmengembalikanListbaru sehingga pemanggil bebas mengiterasi tanpa bisa mengubah peta kategori internal.
Laporan Anggaran Bulanan #
Persentase pemakaian anggaran dihitung sebagai realisasi × 100 ÷ anggaran. Pembagian BigDecimal wajib menyebut skala dan mode pembulatan, karena hasil seperti 830000 ÷ 1500000 tidak berujung dalam desimal; divide(anggaran, 1, RoundingMode.HALF_UP) membulatkan ke satu digit pecahan menjadi 55.3. Status lalu dibaca dari persentase: sampai 80% AMAN, sampai 100% WASPADA, di atasnya LEBIH — pembandingan memakai compareTo, bukan equals, karena compareTo mengabaikan beda skala.
Menjalankan Demo Kategori dan Anggaran #
Berikut pintu masuk Bagian 2 dengan data Januari dan Februari 2026 milik Budi:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Bagian 2: kategori terdaftar, anggaran bulanan, dan validasi silang. */
public class DemoKeuangan {
public static void main(String[] args) throws KategoriTidakTerdahtarException {
BukuKas kas = new BukuKas();
daftarkanKategori(kas);
for (Transaksi t : contohTransaksi()) {
kas.tambah(t);
}
System.out.println("Kategori terdaftar : 6");
System.out.println("Transaksi tercatat : " + kas.jumlahTransaksi());
// Dua upaya menyalahi aturan kategori, keduanya tertolak.
cobaTambah(kas, new Transaksi(LocalDate.of(2026, 2, 3), "Beli obat demam",
JenisTransaksi.PENGELUARAN, "Kesehatan", new BigDecimal("45000.00")));
cobaTambah(kas, new Transaksi(LocalDate.of(2026, 2, 20), "Bonus proyek desain",
JenisTransaksi.PEMASUKAN, "Makanan", new BigDecimal("500000.00")));
System.out.println();
cetakAnggaran(kas, YearMonth.of(2026, 1));
System.out.println();
cetakAnggaran(kas, YearMonth.of(2026, 2));
System.out.println();
System.out.printf(Locale.US, "Saldo akhir per akhir Februari 2026: %,14.2f%n",
kas.saldo());
}
/** Menambah satu transaksi dan melaporkan hasilnya tanpa menghentikan program. */
private static void cobaTambah(BukuKas kas, Transaksi t) {
try {
kas.tambah(t);
System.out.println("DITERIMA : " + t.keterangan());
} catch (KategoriTidakTerdahtarException | IllegalArgumentException e) {
System.out.println("DITOLAK : " + e.getMessage());
}
}
/** Mencetak tabel anggaran satu bulan: anggaran, realisasi, sisa, persen, status. */
private static void cetakAnggaran(BukuKas kas, YearMonth bulan) {
System.out.println("=== Laporan anggaran " + bulan + " ===");
System.out.printf(Locale.US, "%-13s %13s %13s %13s %8s %-8s%n",
"Kategori", "Anggaran", "Realisasi", "Sisa", "Persen", "Status");
Map<String, BigDecimal> realisasi = kas.realisasiPengeluaran(bulan);
for (Kategori k : kas.kategoriBeranggaran()) {
BigDecimal dipakai = realisasi.get(k.nama());
BigDecimal sisa = k.anggaranBulanan().subtract(dipakai);
BigDecimal persen = dipakai.multiply(BigDecimal.valueOf(100))
.divide(k.anggaranBulanan(), 1, RoundingMode.HALF_UP);
System.out.printf(Locale.US, "%-13s %,13.2f %,13.2f %,13.2f %7s%% %-8s%n",
k.nama(), k.anggaranBulanan(), dipakai, sisa,
persen.toPlainString(), status(persen));
}
}
/** Status pemakaian anggaran: AMAN sampai 80%, WASPADA sampai 100%, LEBIH di atasnya. */
private static String status(BigDecimal persen) {
if (persen.compareTo(BigDecimal.valueOf(80)) <= 0) {
return "AMAN";
}
if (persen.compareTo(BigDecimal.valueOf(100)) <= 0) {
return "WASPADA";
}
return "LEBIH";
}
private 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")));
}
private 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")));
}
}Output yang Dihasilkan:
Kategori terdaftar : 6
Transaksi tercatat : 13
DITOLAK : Kategori 'Kesehatan' belum terdaftar di BukuKas
DITOLAK : Kategori 'Makanan' dikhususkan untuk PENGELUARAN, transaksi ini berjenis PEMASUKAN
=== Laporan anggaran 2026-01 ===
Kategori Anggaran Realisasi Sisa Persen Status
Sewa 2,500,000.00 2,500,000.00 0.00 100.0% WASPADA
Makanan 1,500,000.00 830,000.00 670,000.00 55.3% AMAN
Transportasi 600,000.00 350,000.00 250,000.00 58.3% AMAN
Hiburan 500,000.00 250,000.00 250,000.00 50.0% AMAN
=== Laporan anggaran 2026-02 ===
Kategori Anggaran Realisasi Sisa Persen Status
Sewa 2,500,000.00 2,500,000.00 0.00 100.0% WASPADA
Makanan 1,500,000.00 1,290,000.00 210,000.00 86.0% WASPADA
Transportasi 600,000.00 280,000.00 320,000.00 46.7% AMAN
Hiburan 500,000.00 620,000.00 -120,000.00 124.0% LEBIH
Saldo akhir per akhir Februari 2026: 9,580,000.00Empat pengamatan dari keluaran tersebut:
- Dua baris
DITOLAK: transaksi berkategoriKesehatanditolak karena kategori tersebut belum terdaftar, dan pemasukan berkategoriMakananditolak karena jenisnya tidak cocok. Multi-catchKategoriTidakTerdahtarException | IllegalArgumentExceptionmenangani keduanya dalam satu blok tanpa menghentikan program. - Sewa selalu
WASPADA: realisasi 2,500,000.00 tepat menyentuh anggaran sehingga persentase berhenti di100.0%— peringatan batas, bukan pelanggaran. - Hiburan Februari
LEBIH: konser seharga 620,000.00 mendorong realisasi ke124.0%dan sisa berubah menjadi-120,000.00; kolom sisa yang negatif adalah alarm alami darisubtract. - Saldo 9,580,000.00 adalah pemasukan 18,200,000.00 dikurangi pengeluaran 8,620,000.00 — dihitung method
totaldansaldoyang tetap dipakai dari Bagian 1.
Kategori dan anggaran kini menjaga kualitas data, tetapi laporan pada bagian ini masih dirajut loop demi loop. Saat pertanyaan melipat ganda — per bulan, per kategori, nominal besar versus kecil, tren antar bulan — kode loop bertumpuk justru menyulitkan. Bagian 3 membelokkan seluruh perhitungan tersebut ke Stream API dengan groupingBy, partitioningBy, dan koleksi downstream.
Referensi #
- Oracle: Class RoundingMode (Java SE 25 API)
- Oracle: Class BigDecimal (Java SE 25 API)
- Oracle: Class YearMonth (Java SE 25 API)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
BukuKas.java
Unduh berkasimport 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;
}
}
DemoKeuangan.java
Unduh berkasimport java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** Bagian 2: kategori terdaftar, anggaran bulanan, dan validasi silang. */
public class DemoKeuangan {
public static void main(String[] args) throws KategoriTidakTerdahtarException {
BukuKas kas = new BukuKas();
daftarkanKategori(kas);
for (Transaksi t : contohTransaksi()) {
kas.tambah(t);
}
System.out.println("Kategori terdaftar : 6");
System.out.println("Transaksi tercatat : " + kas.jumlahTransaksi());
// Dua upaya menyalahi aturan kategori, keduanya tertolak.
cobaTambah(kas, new Transaksi(LocalDate.of(2026, 2, 3), "Beli obat demam",
JenisTransaksi.PENGELUARAN, "Kesehatan", new BigDecimal("45000.00")));
cobaTambah(kas, new Transaksi(LocalDate.of(2026, 2, 20), "Bonus proyek desain",
JenisTransaksi.PEMASUKAN, "Makanan", new BigDecimal("500000.00")));
System.out.println();
cetakAnggaran(kas, YearMonth.of(2026, 1));
System.out.println();
cetakAnggaran(kas, YearMonth.of(2026, 2));
System.out.println();
System.out.printf(Locale.US, "Saldo akhir per akhir Februari 2026: %,14.2f%n",
kas.saldo());
}
/** Menambah satu transaksi dan melaporkan hasilnya tanpa menghentikan program. */
private static void cobaTambah(BukuKas kas, Transaksi t) {
try {
kas.tambah(t);
System.out.println("DITERIMA : " + t.keterangan());
} catch (KategoriTidakTerdahtarException | IllegalArgumentException e) {
System.out.println("DITOLAK : " + e.getMessage());
}
}
/** Mencetak tabel anggaran satu bulan: anggaran, realisasi, sisa, persen, status. */
private static void cetakAnggaran(BukuKas kas, YearMonth bulan) {
System.out.println("=== Laporan anggaran " + bulan + " ===");
System.out.printf(Locale.US, "%-13s %13s %13s %13s %8s %-8s%n",
"Kategori", "Anggaran", "Realisasi", "Sisa", "Persen", "Status");
Map<String, BigDecimal> realisasi = kas.realisasiPengeluaran(bulan);
for (Kategori k : kas.kategoriBeranggaran()) {
BigDecimal dipakai = realisasi.get(k.nama());
BigDecimal sisa = k.anggaranBulanan().subtract(dipakai);
BigDecimal persen = dipakai.multiply(BigDecimal.valueOf(100))
.divide(k.anggaranBulanan(), 1, RoundingMode.HALF_UP);
System.out.printf(Locale.US, "%-13s %,13.2f %,13.2f %,13.2f %7s%% %-8s%n",
k.nama(), k.anggaranBulanan(), dipakai, sisa,
persen.toPlainString(), status(persen));
}
}
/** Status pemakaian anggaran: AMAN sampai 80%, WASPADA sampai 100%, LEBIH di atasnya. */
private static String status(BigDecimal persen) {
if (persen.compareTo(BigDecimal.valueOf(80)) <= 0) {
return "AMAN";
}
if (persen.compareTo(BigDecimal.valueOf(100)) <= 0) {
return "WASPADA";
}
return "LEBIH";
}
private 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")));
}
private 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")));
}
}
JenisTransaksi.java
Unduh berkas/** Jenis arus kas: uang masuk atau uang keluar. */
public enum JenisTransaksi {
PEMASUKAN,
PENGELUARAN
}
Kategori.java
Unduh berkasimport 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/** Sinyal bahwa transaksi memakai kategori yang belum didaftarkan ke BukuKas. */
public class KategoriTidakTerdahtarException extends Exception {
public KategoriTidakTerdahtarException(String pesan) {
super(pesan);
}
}
Transaksi.java
Unduh berkasimport 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);
}
}