Program berhenti, memori dikosongkan, dan seluruh buku kas Budi ikut terbawa hilang. Bagian 4 menutup proyek dengan dua kemampuan terakhir: menyimpan daftar transaksi ke berkas teks berformat baris, lalu memuatnya kembali dengan pemeriksaan berlapis — tiap baris diperiksa field demi field, sementara checksum pada footer menjaga agar isi berkas tidak berubah diam-diam. Baris cacat ditolak satu per satu dengan laporan nomor baris; checksum yang tak selaras menolak seluruh isi.
Format Baris dan Checksum #
Format penyimpanan dibuat dapat dibaca mata: satu transaksi satu baris, kolom dipisah ;. Baris pertama adalah header BUKUKAS;1, baris data diawali T; dan memuat tanggal ISO, keterangan, jenis, kategori, serta nominal, dan baris terakhir CHECKSUM;<angka> merangkum seluruh isi di atasnya. Checksum-nya sengaja sederhana: penjumlahan nilai karakter Unicode seluruh baris sebelum footer — bukan kriptografi, cukup mendeteksi perubahan tak disengaja.
Dua konsekuensi format perlu dijaga: karakter ; pada keterangan atau kategori merusak pembacaan kolom sehingga penyimpanan menolaknya lebih awal, dan nominal ditulis dengan toPlainString() — toString() pada BigDecimal tertentu memilih notasi ilmiah seperti 8.5E+6 yang menggagalkan pembacaan kembali.
Kelas PenyimpananKas #
Seluruh tanggung jawab berkas berada di satu kelas utilitas dengan dua pintu: simpan dan muat:
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Menyimpan dan memuat daftar transaksi lewat berkas teks berformat baris.
* Tiap baris data berbentuk T;tanggal;keterangan;jenis;kategori;nominal,
* seluruh berkas ditutup satu baris CHECKSUM.
*/
public final class PenyimpananKas {
public static final String HEADER = "BUKUKAS;1";
public static final String PREFIX_DATA = "T;";
public static final String PREFIX_CHECKSUM = "CHECKSUM;";
/** Hasil memuat: transaksi yang lolos validasi plus daftar galat per baris. */
public record HasilMuat(List<Transaksi> transaksi, List<String> galat) {
/** Benar-benar bersih bila tidak ada satu pun galat. */
public boolean bersih() {
return galat.isEmpty();
}
}
private PenyimpananKas() {
// Kelas utilitas: seluruh anggota static, tidak perlu instance.
}
/** Menulis seluruh transaksi ke berkas UTF-8, ditutup baris checksum. */
public static void simpan(List<Transaksi> transaksi, Path berkas) throws IOException {
List<String> baris = new ArrayList<>();
baris.add(HEADER);
for (Transaksi t : transaksi) {
if (t.keterangan().indexOf(';') >= 0 || t.kategori().indexOf(';') >= 0) {
throw new IllegalArgumentException("Keterangan dan kategori tidak boleh "
+ "mengandung ';' karena bertabrakan dengan pemisah kolom");
}
baris.add(PREFIX_DATA + t.tanggal() + ";" + t.keterangan() + ";" + t.jenis()
+ ";" + t.kategori() + ";" + t.jumlah().toPlainString());
}
baris.add(PREFIX_CHECKSUM + checksum(baris));
Files.write(berkas, baris, StandardCharsets.UTF_8);
}
/**
* Checksum sederhana: penjumlahan nilai karakter Unicode seluruh baris yang diberikan.
* Angka ini bukan pengaman kriptografi, hanya pendeteksi perubahan tak disengaja.
*/
public static long checksum(List<String> baris) {
long total = 0;
for (String b : baris) {
for (int i = 0; i < b.length(); i++) {
total += b.charAt(i);
}
}
return total;
}
/**
* Memuat berkas. Baris cacat ditolak satu per satu dengan laporan;
* checksum yang tidak selaras menolak seluruh isi karena integritas data diragukan.
*/
public static HasilMuat muat(Path berkas) throws IOException {
List<String> isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
List<Transaksi> valid = new ArrayList<>();
List<String> galat = new ArrayList<>();
if (isi.isEmpty() || !HEADER.equals(isi.get(0))) {
galat.add("Baris 1: header '"
+ (isi.isEmpty() ? "(berkas kosong)" : isi.get(0))
+ "' tidak dikenali, harap '" + HEADER + "'");
return new HasilMuat(List.of(), galat);
}
if (!cekChecksum(isi, galat)) {
return new HasilMuat(List.of(), galat);
}
for (int i = 1; i < isi.size() - 1; i++) {
String baris = isi.get(i);
if (baris.isBlank()) {
galat.add("Baris " + (i + 1) + ": baris kosong");
} else if (!baris.startsWith(PREFIX_DATA)) {
galat.add("Baris " + (i + 1) + ": awalan baris tidak dikenali");
} else {
try {
valid.add(parse(baris));
} catch (IllegalArgumentException e) {
galat.add("Baris " + (i + 1) + ": " + e.getMessage());
}
}
}
return new HasilMuat(valid, galat);
}
/** Membandingkan checksum tercatat pada footer dengan hasil hitung ulang. */
private static boolean cekChecksum(List<String> isi, List<String> galat) {
String footer = isi.get(isi.size() - 1);
if (!footer.startsWith(PREFIX_CHECKSUM)) {
galat.add("Baris " + isi.size() + ": footer CHECKSUM tidak ditemukan");
return false;
}
long tercatat;
try {
tercatat = Long.parseLong(footer.substring(PREFIX_CHECKSUM.length()));
} catch (NumberFormatException e) {
galat.add("Baris " + isi.size() + ": angka checksum tidak terbaca");
return false;
}
long dihitung = checksum(isi.subList(0, isi.size() - 1));
if (tercatat != dihitung) {
galat.add("Checksum tidak selaras: tercatat " + tercatat
+ ", dihitung " + dihitung);
return false;
}
return true;
}
/** Memecah satu baris data menjadi Transaksi dengan pesan galat berbahasa Indonesia. */
private static Transaksi parse(String baris) {
String[] kolom = baris.split(";", -1);
if (kolom.length != 6) {
throw new IllegalArgumentException("jumlah kolom " + kolom.length + ", harap 6");
}
LocalDate tanggal;
try {
tanggal = LocalDate.parse(kolom[1]);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("tanggal '" + kolom[1] + "' tidak valid");
}
JenisTransaksi jenis;
try {
jenis = JenisTransaksi.valueOf(kolom[3]);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("jenis '" + kolom[3] + "' tidak dikenal");
}
BigDecimal nominal;
try {
nominal = new BigDecimal(kolom[5]);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("nominal '" + kolom[5] + "' bukan angka");
}
return new Transaksi(tanggal, kolom[2], jenis, kolom[4], nominal);
}
}Mari kita bedah keputusan-keputusan pentingnya:
HasilMuatsebagai record bersarang: hasil muat selalu berpasangan — data yang lolos dan daftar galat — sehingga pemanggil tidak bisa membaca satu tanpa yang lain; methodbersih()meringkasgalat.isEmpty().simpanmenutup dengan checksum seluruh baris termasuk header; footer tidak ikut dihitung karena baru ditulis setelah angka dirangkai. CharsetUTF_8dinyatakan eksplisit padaFiles.write.muatmemeriksa checksum lebih dulu: header yang tidak dikenal atau footer yang hilang langsung menghentikan pemuatan dengan hasil kosong karena tanpa footer tidak ada jalan memastikan isi berkas utuh.- Loop data membaca indeks
1sampaisize - 2: indeks 0 adalah header dan indeks terakhir footer. Tiap penolakan menyebut nomor baris asli (i + 1) agar baris bermasalah mudah ditemukan. parsemembungkus tiga exception bawaan menjadiIllegalArgumentExceptionberpesan Indonesia, dan compact constructorTransaksimenjadi gerbang terakhir — nominal negatif pada berkas tertolak oleh aturan yang sama dengan nominal dari kode.
Menyimpan lalu Memuat Ulang #
Demo Bagian 4 memainkan empat babak: simpan, muat ulang, muat berkas cacat, dan muat berkas yang dimanipulasi diam-diam:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Bagian 4: menyimpan buku kas ke berkas, lalu memuatnya kembali dengan validasi. */
public class DemoKeuangan {
public static void main(String[] args) throws Exception {
BukuKas kas = new BukuKas();
DemoData.daftarkanKategori(kas);
for (Transaksi t : DemoData.contohTransaksi()) {
kas.tambah(t);
}
// 1) Simpan seluruh buku kas lalu peragakan isi berkasnya.
Path berkas = Path.of("kas-budi.txt");
PenyimpananKas.simpan(kas.semua(), berkas);
System.out.println("Tersimpan " + kas.jumlahTransaksi() + " transaksi ke " + berkas);
System.out.println();
System.out.println("=== Isi kas-budi.txt ===");
System.out.print(Files.readString(berkas, StandardCharsets.UTF_8));
System.out.println();
// 2) Muat ulang: seluruh baris harus lolos validasi dan checksum.
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
System.out.println("=== Muat ulang kas-budi.txt ===");
System.out.println("Transaksi dimuat : " + hasil.transaksi().size());
System.out.println("Galat : " + hasil.galat().size());
BukuKas kasBaru = new BukuKas();
DemoData.daftarkanKategori(kasBaru);
for (Transaksi t : hasil.transaksi()) {
kasBaru.tambah(t);
}
System.out.printf(Locale.US, "Saldo asli : %,14.2f%n", kas.saldo());
System.out.printf(Locale.US, "Saldo dimuat : %,14.2f%n", kasBaru.saldo());
System.out.println("Data identik : " + kasBaru.semua().equals(kas.semua()));
// 3) Berkas cacat: tiga baris rusak, checksum sengaja dihitung ulang benar.
Path cacat = Path.of("kas-cacat.txt");
List<String> barisCacat = new ArrayList<>();
barisCacat.add(PenyimpananKas.HEADER);
barisCacat.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
barisCacat.add("T;15-01-2026;Tanggal terbalik;PENGELUARAN;Sewa;250000.00");
barisCacat.add("T;2026-01-07;Sewa tempat tinggal Januari;PENGELUARAN;Sewa;2500000.00");
barisCacat.add("T;2026-01-10;Belanja bahan makanan;PENGELUARAN;Makanan;bukan-angka");
barisCacat.add("T;2026-01-18;Makan di luar bersama Siti;KELUAR;Makanan;380000.00");
barisCacat.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(barisCacat));
Files.write(cacat, barisCacat, StandardCharsets.UTF_8);
System.out.println();
System.out.println("=== Muat kas-cacat.txt ===");
PenyimpananKas.HasilMuat hasilCacat = PenyimpananKas.muat(cacat);
for (String g : hasilCacat.galat()) {
System.out.println("GALAT: " + g);
}
System.out.println("Transaksi lolos : " + hasilCacat.transaksi().size());
// 4) Berkas yang nominalnya diubah diam-diam setelah checksum ditulis.
Path manipulasi = Path.of("kas-manipulasi.txt");
List<String> barisUbah = new ArrayList<>(Files.readAllLines(berkas, StandardCharsets.UTF_8));
barisUbah.set(1, barisUbah.get(1).replace("8500000.00", "8500000.01"));
Files.write(manipulasi, barisUbah, StandardCharsets.UTF_8);
System.out.println();
System.out.println("=== Muat kas-manipulasi.txt ===");
PenyimpananKas.HasilMuat hasilManip = PenyimpananKas.muat(manipulasi);
for (String g : hasilManip.galat()) {
System.out.println("GALAT: " + g);
}
System.out.println("Transaksi lolos : " + hasilManip.transaksi().size()
+ " — seluruh isi ditolak karena integritas diragukan");
}
}Output yang Dihasilkan:
Tersimpan 19 transaksi ke kas-budi.txt
=== Isi kas-budi.txt ===
BUKUKAS;1
T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00
T;2026-01-07;Sewa tempat tinggal Januari;PENGELUARAN;Sewa;2500000.00
T;2026-01-10;Belanja bahan makanan;PENGELUARAN;Makanan;450000.00
T;2026-01-18;Makan di luar bersama Siti;PENGELUARAN;Makanan;380000.00
T;2026-01-22;Isi bensin;PENGELUARAN;Transportasi;350000.00
T;2026-01-28;Langganan streaming;PENGELUARAN;Hiburan;250000.00
T;2026-02-05;Gaji bulanan Februari;PEMASUKAN;Gaji;8500000.00
T;2026-02-07;Sewa tempat tinggal Februari;PENGELUARAN;Sewa;2500000.00
T;2026-02-12;Belanja bahan makanan;PENGELUARAN;Makanan;770000.00
T;2026-02-14;Proyek desain lepas;PEMASUKAN;Freelance;1200000.00
T;2026-02-18;Makan di luar bersama Siti;PENGELUARAN;Makanan;520000.00
T;2026-02-19;Isi bensin;PENGELUARAN;Transportasi;280000.00
T;2026-02-25;Tiket konser musik;PENGELUARAN;Hiburan;620000.00
T;2026-03-05;Gaji bulanan Maret;PEMASUKAN;Gaji;8500000.00
T;2026-03-06;Sewa tempat tinggal Maret;PENGELUARAN;Sewa;2500000.00
T;2026-03-11;Belanja bahan makanan;PENGELUARAN;Makanan;610000.00
T;2026-03-14;Makan siang di luar;PENGELUARAN;Makanan;240000.00
T;2026-03-20;Isi bensin;PENGELUARAN;Transportasi;310000.00
T;2026-03-27;Tiket bioskop;PENGELUARAN;Hiburan;180000.00
CHECKSUM;89772
=== Muat ulang kas-budi.txt ===
Transaksi dimuat : 19
Galat : 0
Saldo asli : 14,240,000.00
Saldo dimuat : 14,240,000.00
Data identik : true
=== Muat kas-cacat.txt ===
GALAT: Baris 3: tanggal '15-01-2026' tidak valid
GALAT: Baris 5: nominal 'bukan-angka' bukan angka
GALAT: Baris 6: jenis 'KELUAR' tidak dikenal
Transaksi lolos : 2
=== Muat kas-manipulasi.txt ===
GALAT: Checksum tidak selaras: tercatat 89772, dihitung 89773
Transaksi lolos : 0 — seluruh isi ditolak karena integritas diragukanTiga pelajaran dari keluaran tersebut:
- Bolak balik utuh:
Data identikbernilaitrue—toPlainString()menjaga skala dua digit sehingga8500000.00kembali sebagaiBigDecimalyang setara. - Berkas cacat memutus per baris: tiga
GALATmenyebut baris 3 (tanggal terbalik), baris 5 (nominal bukan angka), dan baris 6 (jenisKELUARtidak dikenal); dua baris sehat tetap termuat karena checksum berkas itu memang dihitung ulang. - Manipulasi diam-diam ketahuan: satu digit yang bergeser (
8500000.00menjadi8500000.01) memindahkan checksum dari 89772 ke 89773; tiap baris tampak sehat, namunmuatmenolak semuanya — lapis kedua menangkap perubahan yang lolos dari validasi field.
Harness Pengujian Berbasis main #
Aplikasi murni JDK tidak butuh pustaka pengujian untuk menyatakan benar-salah. Harness berikut memakai pola cek(nama, kondisi) penghitung PASS/FAIL yang keluar dengan exit code 1 bila ada kegagalan:
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** Harness pengujian berbasis main: PASS/FAIL tanpa pustaka eksternal. */
public class UjiPenyimpananKas {
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws IOException {
ujiBolakBalik();
ujiBarisCacat();
ujiNominalNegatif();
ujiChecksum();
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1); // exit code memberi sinyal ke proses pemanggil
}
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void cek(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/** Simpan lalu muat: data yang kembali harus identik dengan yang disimpan. */
private static void ujiBolakBalik() throws IOException {
List<Transaksi> data = 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, 10), "Belanja bahan makanan",
JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")));
Path berkas = Path.of("uji-bolak-balik.txt");
PenyimpananKas.simpan(data, berkas);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("bolak balik memuat 2 transaksi", hasil.transaksi().size() == 2);
cek("bolak balik identik dengan data asli", hasil.transaksi().equals(data));
cek("bolak balik tanpa galat", hasil.bersih());
}
/** Baris cacat ditolak satu per satu; baris sehat tetap termuat. */
private static void ujiBarisCacat() throws IOException {
List<String> baris = new ArrayList<>();
baris.add(PenyimpananKas.HEADER);
baris.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
baris.add("T;2026-01-10;Belanja;PENGELUARAN;Makanan;bukan-angka");
baris.add("T;2026-01-18;;PENGELUARAN;Makanan;380000.00");
baris.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(baris));
Path berkas = Path.of("uji-baris-cacat.txt");
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("baris cacat memuat 1 transaksi", hasil.transaksi().size() == 1);
cek("baris cacat melaporkan 2 galat", hasil.galat().size() == 2);
cek("galat pertama menunjuk baris 3", hasil.galat().get(0).startsWith("Baris 3"));
}
/** Nominal negatif lolos parsing angka namun ditolak validasi record. */
private static void ujiNominalNegatif() throws IOException {
List<String> baris = new ArrayList<>();
baris.add(PenyimpananKas.HEADER);
baris.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
baris.add("T;2026-01-10;Pengeluaran palsu;PENGELUARAN;Makanan;-500.00");
baris.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(baris));
Path berkas = Path.of("uji-nominal-negatif.txt");
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("nominal negatif ditolak validasi record", hasil.galat().size() == 1
&& hasil.galat().get(0).contains("positif"));
cek("nominal negatif tidak termuat", hasil.transaksi().size() == 1);
}
/** Nominal yang diubah tanpa pembaruan checksum harus menutup seluruh berkas. */
private static void ujiChecksum() throws IOException {
List<Transaksi> data = 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, 10), "Belanja bahan makanan",
JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")));
Path berkas = Path.of("uji-checksum.txt");
PenyimpananKas.simpan(data, berkas);
// Mengubah satu nominal tanpa memperbarui checksum di footer.
List<String> baris = new ArrayList<>(Files.readAllLines(berkas, StandardCharsets.UTF_8));
baris.set(2, baris.get(2).replace("450000.00", "450000.50"));
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("checksum gagal menolak seluruh data", hasil.transaksi().isEmpty());
cek("checksum gagal melaporkan ketidakselarasan",
!hasil.bersih() && hasil.galat().get(0).startsWith("Checksum"));
}
}Output yang Dihasilkan:
PASS: bolak balik memuat 2 transaksi
PASS: bolak balik identik dengan data asli
PASS: bolak balik tanpa galat
PASS: baris cacat memuat 1 transaksi
PASS: baris cacat melaporkan 2 galat
PASS: galat pertama menunjuk baris 3
PASS: nominal negatif ditolak validasi record
PASS: nominal negatif tidak termuat
PASS: checksum gagal menolak seluruh data
PASS: checksum gagal melaporkan ketidakselarasan
Ringkasan: 10 lulus, 0 gagalEmpat pengujian menutup perilaku inti: bolak balik identik, penolakan per baris, validasi nominal negatif, dan penolakan menyeluruh akibat checksum. Padanan JUnit 5-nya ditampilkan berikut — JUnit bukan bagian JDK, sehingga tanpa klaim eksekusi:
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PenyimpananKasTest {
@Test
void bolakBalikMenyimpanDanMemuat() throws Exception {
List<Transaksi> data = List.of(
new Transaksi(LocalDate.of(2026, 1, 5), "Gaji bulanan Januari",
JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")));
Path berkas = Path.of("uji-bolak-balik.txt");
PenyimpananKas.simpan(data, berkas);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
assertEquals(data, hasil.transaksi());
assertTrue(hasil.bersih());
}
@Test
void barisCacatDitolakSatuPerSatu() throws Exception {
List<String> baris = new ArrayList<>();
baris.add(PenyimpananKas.HEADER);
baris.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
baris.add("T;2026-01-10;Belanja;PENGELUARAN;Makanan;bukan-angka");
baris.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(baris));
Path berkas = Path.of("uji-baris-cacat.txt");
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
assertEquals(1, hasil.transaksi().size());
assertEquals(1, hasil.galat().size());
}
@Test
void checksumGagalMenolakSeluruhData() throws Exception {
List<Transaksi> data = List.of(
new Transaksi(LocalDate.of(2026, 1, 5), "Gaji bulanan Januari",
JenisTransaksi.PEMASUKAN, "Gaji", new BigDecimal("8500000.00")));
Path berkas = Path.of("uji-checksum.txt");
PenyimpananKas.simpan(data, berkas);
List<String> baris = new ArrayList<>(Files.readAllLines(berkas, StandardCharsets.UTF_8));
baris.set(1, baris.get(1).replace("8500000.00", "8500000.01"));
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
assertTrue(hasil.transaksi().isEmpty());
assertFalse(hasil.bersih());
}
}Proyek aplikasi keuangan pribadi kini utuh: model Transaksi yang menjaga dirinya sendiri, kategori dan anggaran tervalidasi silang, laporan hasil Stream API, dan data yang bertahan di berkas dengan integritas terjaga. Seluruhnya hanya memakai JDK, tanpa satu pun pustaka luar. Arah pengembangan berikutnya terbuka: antarmuka baris perintah interaktif, format JSON, atau database sungguhan — semuanya tinggal menambal lapisan terluar karena fondasinya kokoh.
Referensi #
- Oracle: Class Files (Java SE 25 API)
- Oracle: Class StandardCharsets (Java SE 25 API)
- dev.java — Java I/O
- JUnit 5 User Guide
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;
}
}
DemoData.java
Unduh berkasimport 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 berkasimport java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Bagian 4: menyimpan buku kas ke berkas, lalu memuatnya kembali dengan validasi. */
public class DemoKeuangan {
public static void main(String[] args) throws Exception {
BukuKas kas = new BukuKas();
DemoData.daftarkanKategori(kas);
for (Transaksi t : DemoData.contohTransaksi()) {
kas.tambah(t);
}
// 1) Simpan seluruh buku kas lalu peragakan isi berkasnya.
Path berkas = Path.of("kas-budi.txt");
PenyimpananKas.simpan(kas.semua(), berkas);
System.out.println("Tersimpan " + kas.jumlahTransaksi() + " transaksi ke " + berkas);
System.out.println();
System.out.println("=== Isi kas-budi.txt ===");
System.out.print(Files.readString(berkas, StandardCharsets.UTF_8));
System.out.println();
// 2) Muat ulang: seluruh baris harus lolos validasi dan checksum.
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
System.out.println("=== Muat ulang kas-budi.txt ===");
System.out.println("Transaksi dimuat : " + hasil.transaksi().size());
System.out.println("Galat : " + hasil.galat().size());
BukuKas kasBaru = new BukuKas();
DemoData.daftarkanKategori(kasBaru);
for (Transaksi t : hasil.transaksi()) {
kasBaru.tambah(t);
}
System.out.printf(Locale.US, "Saldo asli : %,14.2f%n", kas.saldo());
System.out.printf(Locale.US, "Saldo dimuat : %,14.2f%n", kasBaru.saldo());
System.out.println("Data identik : " + kasBaru.semua().equals(kas.semua()));
// 3) Berkas cacat: tiga baris rusak, checksum sengaja dihitung ulang benar.
Path cacat = Path.of("kas-cacat.txt");
List<String> barisCacat = new ArrayList<>();
barisCacat.add(PenyimpananKas.HEADER);
barisCacat.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
barisCacat.add("T;15-01-2026;Tanggal terbalik;PENGELUARAN;Sewa;250000.00");
barisCacat.add("T;2026-01-07;Sewa tempat tinggal Januari;PENGELUARAN;Sewa;2500000.00");
barisCacat.add("T;2026-01-10;Belanja bahan makanan;PENGELUARAN;Makanan;bukan-angka");
barisCacat.add("T;2026-01-18;Makan di luar bersama Siti;KELUAR;Makanan;380000.00");
barisCacat.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(barisCacat));
Files.write(cacat, barisCacat, StandardCharsets.UTF_8);
System.out.println();
System.out.println("=== Muat kas-cacat.txt ===");
PenyimpananKas.HasilMuat hasilCacat = PenyimpananKas.muat(cacat);
for (String g : hasilCacat.galat()) {
System.out.println("GALAT: " + g);
}
System.out.println("Transaksi lolos : " + hasilCacat.transaksi().size());
// 4) Berkas yang nominalnya diubah diam-diam setelah checksum ditulis.
Path manipulasi = Path.of("kas-manipulasi.txt");
List<String> barisUbah = new ArrayList<>(Files.readAllLines(berkas, StandardCharsets.UTF_8));
barisUbah.set(1, barisUbah.get(1).replace("8500000.00", "8500000.01"));
Files.write(manipulasi, barisUbah, StandardCharsets.UTF_8);
System.out.println();
System.out.println("=== Muat kas-manipulasi.txt ===");
PenyimpananKas.HasilMuat hasilManip = PenyimpananKas.muat(manipulasi);
for (String g : hasilManip.galat()) {
System.out.println("GALAT: " + g);
}
System.out.println("Transaksi lolos : " + hasilManip.transaksi().size()
+ " — seluruh isi ditolak karena integritas diragukan");
}
}
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);
}
}
PenyimpananKas.java
Unduh berkasimport java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Menyimpan dan memuat daftar transaksi lewat berkas teks berformat baris.
* Tiap baris data berbentuk T;tanggal;keterangan;jenis;kategori;nominal,
* seluruh berkas ditutup satu baris CHECKSUM.
*/
public final class PenyimpananKas {
public static final String HEADER = "BUKUKAS;1";
public static final String PREFIX_DATA = "T;";
public static final String PREFIX_CHECKSUM = "CHECKSUM;";
/** Hasil memuat: transaksi yang lolos validasi plus daftar galat per baris. */
public record HasilMuat(List<Transaksi> transaksi, List<String> galat) {
/** Benar-benar bersih bila tidak ada satu pun galat. */
public boolean bersih() {
return galat.isEmpty();
}
}
private PenyimpananKas() {
// Kelas utilitas: seluruh anggota static, tidak perlu instance.
}
/** Menulis seluruh transaksi ke berkas UTF-8, ditutup baris checksum. */
public static void simpan(List<Transaksi> transaksi, Path berkas) throws IOException {
List<String> baris = new ArrayList<>();
baris.add(HEADER);
for (Transaksi t : transaksi) {
if (t.keterangan().indexOf(';') >= 0 || t.kategori().indexOf(';') >= 0) {
throw new IllegalArgumentException("Keterangan dan kategori tidak boleh "
+ "mengandung ';' karena bertabrakan dengan pemisah kolom");
}
baris.add(PREFIX_DATA + t.tanggal() + ";" + t.keterangan() + ";" + t.jenis()
+ ";" + t.kategori() + ";" + t.jumlah().toPlainString());
}
baris.add(PREFIX_CHECKSUM + checksum(baris));
Files.write(berkas, baris, StandardCharsets.UTF_8);
}
/**
* Checksum sederhana: penjumlahan nilai karakter Unicode seluruh baris yang diberikan.
* Angka ini bukan pengaman kriptografi, hanya pendeteksi perubahan tak disengaja.
*/
public static long checksum(List<String> baris) {
long total = 0;
for (String b : baris) {
for (int i = 0; i < b.length(); i++) {
total += b.charAt(i);
}
}
return total;
}
/**
* Memuat berkas. Baris cacat ditolak satu per satu dengan laporan;
* checksum yang tidak selaras menolak seluruh isi karena integritas data diragukan.
*/
public static HasilMuat muat(Path berkas) throws IOException {
List<String> isi = Files.readAllLines(berkas, StandardCharsets.UTF_8);
List<Transaksi> valid = new ArrayList<>();
List<String> galat = new ArrayList<>();
if (isi.isEmpty() || !HEADER.equals(isi.get(0))) {
galat.add("Baris 1: header '"
+ (isi.isEmpty() ? "(berkas kosong)" : isi.get(0))
+ "' tidak dikenali, harap '" + HEADER + "'");
return new HasilMuat(List.of(), galat);
}
if (!cekChecksum(isi, galat)) {
return new HasilMuat(List.of(), galat);
}
for (int i = 1; i < isi.size() - 1; i++) {
String baris = isi.get(i);
if (baris.isBlank()) {
galat.add("Baris " + (i + 1) + ": baris kosong");
} else if (!baris.startsWith(PREFIX_DATA)) {
galat.add("Baris " + (i + 1) + ": awalan baris tidak dikenali");
} else {
try {
valid.add(parse(baris));
} catch (IllegalArgumentException e) {
galat.add("Baris " + (i + 1) + ": " + e.getMessage());
}
}
}
return new HasilMuat(valid, galat);
}
/** Membandingkan checksum tercatat pada footer dengan hasil hitung ulang. */
private static boolean cekChecksum(List<String> isi, List<String> galat) {
String footer = isi.get(isi.size() - 1);
if (!footer.startsWith(PREFIX_CHECKSUM)) {
galat.add("Baris " + isi.size() + ": footer CHECKSUM tidak ditemukan");
return false;
}
long tercatat;
try {
tercatat = Long.parseLong(footer.substring(PREFIX_CHECKSUM.length()));
} catch (NumberFormatException e) {
galat.add("Baris " + isi.size() + ": angka checksum tidak terbaca");
return false;
}
long dihitung = checksum(isi.subList(0, isi.size() - 1));
if (tercatat != dihitung) {
galat.add("Checksum tidak selaras: tercatat " + tercatat
+ ", dihitung " + dihitung);
return false;
}
return true;
}
/** Memecah satu baris data menjadi Transaksi dengan pesan galat berbahasa Indonesia. */
private static Transaksi parse(String baris) {
String[] kolom = baris.split(";", -1);
if (kolom.length != 6) {
throw new IllegalArgumentException("jumlah kolom " + kolom.length + ", harap 6");
}
LocalDate tanggal;
try {
tanggal = LocalDate.parse(kolom[1]);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("tanggal '" + kolom[1] + "' tidak valid");
}
JenisTransaksi jenis;
try {
jenis = JenisTransaksi.valueOf(kolom[3]);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("jenis '" + kolom[3] + "' tidak dikenal");
}
BigDecimal nominal;
try {
nominal = new BigDecimal(kolom[5]);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("nominal '" + kolom[5] + "' bukan angka");
}
return new Transaksi(tanggal, kolom[2], jenis, kolom[4], nominal);
}
}
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);
}
}
UjiPenyimpananKas.java
Unduh berkasimport java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/** Harness pengujian berbasis main: PASS/FAIL tanpa pustaka eksternal. */
public class UjiPenyimpananKas {
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws IOException {
ujiBolakBalik();
ujiBarisCacat();
ujiNominalNegatif();
ujiChecksum();
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1); // exit code memberi sinyal ke proses pemanggil
}
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void cek(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/** Simpan lalu muat: data yang kembali harus identik dengan yang disimpan. */
private static void ujiBolakBalik() throws IOException {
List<Transaksi> data = 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, 10), "Belanja bahan makanan",
JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")));
Path berkas = Path.of("uji-bolak-balik.txt");
PenyimpananKas.simpan(data, berkas);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("bolak balik memuat 2 transaksi", hasil.transaksi().size() == 2);
cek("bolak balik identik dengan data asli", hasil.transaksi().equals(data));
cek("bolak balik tanpa galat", hasil.bersih());
}
/** Baris cacat ditolak satu per satu; baris sehat tetap termuat. */
private static void ujiBarisCacat() throws IOException {
List<String> baris = new ArrayList<>();
baris.add(PenyimpananKas.HEADER);
baris.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
baris.add("T;2026-01-10;Belanja;PENGELUARAN;Makanan;bukan-angka");
baris.add("T;2026-01-18;;PENGELUARAN;Makanan;380000.00");
baris.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(baris));
Path berkas = Path.of("uji-baris-cacat.txt");
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("baris cacat memuat 1 transaksi", hasil.transaksi().size() == 1);
cek("baris cacat melaporkan 2 galat", hasil.galat().size() == 2);
cek("galat pertama menunjuk baris 3", hasil.galat().get(0).startsWith("Baris 3"));
}
/** Nominal negatif lolos parsing angka namun ditolak validasi record. */
private static void ujiNominalNegatif() throws IOException {
List<String> baris = new ArrayList<>();
baris.add(PenyimpananKas.HEADER);
baris.add("T;2026-01-05;Gaji bulanan Januari;PEMASUKAN;Gaji;8500000.00");
baris.add("T;2026-01-10;Pengeluaran palsu;PENGELUARAN;Makanan;-500.00");
baris.add(PenyimpananKas.PREFIX_CHECKSUM + PenyimpananKas.checksum(baris));
Path berkas = Path.of("uji-nominal-negatif.txt");
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("nominal negatif ditolak validasi record", hasil.galat().size() == 1
&& hasil.galat().get(0).contains("positif"));
cek("nominal negatif tidak termuat", hasil.transaksi().size() == 1);
}
/** Nominal yang diubah tanpa pembaruan checksum harus menutup seluruh berkas. */
private static void ujiChecksum() throws IOException {
List<Transaksi> data = 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, 10), "Belanja bahan makanan",
JenisTransaksi.PENGELUARAN, "Makanan", new BigDecimal("450000.00")));
Path berkas = Path.of("uji-checksum.txt");
PenyimpananKas.simpan(data, berkas);
// Mengubah satu nominal tanpa memperbarui checksum di footer.
List<String> baris = new ArrayList<>(Files.readAllLines(berkas, StandardCharsets.UTF_8));
baris.set(2, baris.get(2).replace("450000.00", "450000.50"));
Files.write(berkas, baris, StandardCharsets.UTF_8);
PenyimpananKas.HasilMuat hasil = PenyimpananKas.muat(berkas);
cek("checksum gagal menolak seluruh data", hasil.transaksi().isEmpty());
cek("checksum gagal melaporkan ketidakselarasan",
!hasil.bersih() && hasil.galat().get(0).startsWith("Checksum"));
}
}