Tujuan
Di proyek ini kamu membangun aplikasi Pengelola Keuangan Pribadi yang berjalan di konsol (Node.js) dan juga bisa dipakai di Console browser. Aplikasi mencatat pemasukan dan pengeluaran, menghitung saldo, menyusun laporan bulanan berdasarkan tanggal WIB, memberi peringatan tagihan yang mendekati jatuh tempo, serta mengonversi saldo ke mata uang asing dengan mengambil kurs secara asinkron (simulasi API yang kadang lambat atau gagal).
Proyek ini sengaja dirancang untuk menguji semua materi Fase 1 sekaligus. Kamu akan memakai class dengan private field, getter, static, dan pewarisan (termasuk custom error), array method (filter, map, reduce, sort) dan destructuring, Date dan Intl.DateTimeFormat untuk format tanggal Indonesia dan perhitungan hari, error handling dengan error class sendiri, serta async/await, Promise.allSettled, timeout, dan retry. Hasil akhirnya bukan sekadar latihan. Ini program yang bisa benar-benar kamu pakai untuk mencatat keuangan bulananmu, dan cocok dimasukkan ke portofolio GitHub pertamamu.
Kerjakan proyek ini secara bertahap. Jangan langsung menyalin kode kerangka. Baca dulu tiap langkah, coba tulis sendiri, baru bandingkan. Kode kerangka di bawah sudah berjalan dan memberi output nyata, tetapi beberapa bagian sengaja ditandai TODO untuk kamu lengkapi.
Fitur wajib
- Model data dengan class.
Transaksi(id otomatis, jenis"masuk"/"keluar", jumlah, kategori, keterangan, tanggal) dengan validasi di constructor. - Custom error. Minimal
ValidasiErrordanSaldoTidakCukupError, keduanya turunan dariKeuanganError extends Error. - Dompet. Class
Dompetdengan private field#transaksi, gettersaldo, methodtambah(),hapus(id), danriwayat()yang mengembalikan salinan. Pengeluaran yang membuat saldo negatif ditolak. - Laporan bulanan. Total masuk, total keluar, selisih, dan rincian pengeluaran per kategori untuk bulan tertentu, dikelompokkan berdasarkan tanggal WIB (
Asia/Jakarta), diurutkan dari kategori terbesar. - Format Indonesia. Rupiah memakai
Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR" }), tanggal memakaiIntl.DateTimeFormat("id-ID"). - Pengingat tagihan. Daftar tagihan
{ nama, jumlah, jatuhTempo }dengan status "x hari lagi", "hari ini", atau "terlambat x hari" memakai selisih hari kalender. - Kurs asinkron. Function
ambilKurs(kode)yang mengembalikan Promise, dibungkus timeout dan retry. Konversi saldo ke beberapa mata uang memakaiPromise.allSettledsehingga satu kurs gagal tidak menggagalkan semuanya. - Penanganan error di level atas. Program tidak pernah berhenti karena unhandled rejection; semua error ditampilkan dengan pesan yang jelas.
Langkah bertahap
Langkah 1, Siapkan proyek. Buat folder keuangan-ku, lalu file app.js. Pastikan Node.js versi 20 ke atas (node -v). Jalankan dengan node app.js.
Langkah 2, Buat utilitas format. Tulis formatRupiah(n) dan formatTanggal(date) sebagai formatter Intl yang dibuat sekali di bagian atas file. Tambahkan kunciBulanWIB(date) yang mengembalikan "2026-10" berdasarkan zona Asia/Jakarta (ingat jebakan toISOString() yang selalu UTC).
Langkah 3, Buat hierarki error. KeuanganError extends Error dengan this.name = this.constructor.name, lalu ValidasiError (punya properti field) dan SaldoTidakCukupError (punya kekurangan).
Langkah 4, Class Transaksi. Terima satu object opsi lewat destructuring dengan default (kategori = "lainnya", tanggal = new Date()). Validasi: jenis hanya masuk/keluar, jumlah angka positif, tanggal valid (getTime() bukan NaN). Pakai static #berikutnya untuk id otomatis. Tambahkan getter nilaiBertanda (positif untuk masuk, negatif untuk keluar).
Langkah 5, Class Dompet. Simpan transaksi di #transaksi. Getter saldo menghitung dengan reduce. Method tambah() membuat Transaksi baru dan menolak pengeluaran melebihi saldo dengan SaldoTidakCukupError. Method riwayat({ kategori, jenis } = {}) mengembalikan salinan terfilter, diurutkan terbaru dulu.
Langkah 6, Laporan bulanan. Method laporanBulan(kunci) memfilter transaksi berdasarkan kunciBulanWIB, menjumlah masuk/keluar, mengelompokkan pengeluaran per kategori dengan reduce, lalu mengurutkannya dengan Object.entries(...).sort(...). Tampilkan dengan console.table.
Langkah 7, Pengingat tagihan. Tulis selisihHari(dari, ke) memakai Date.UTC dan statusTagihan(tagihan, hariIni). Jadikan hariIni sebagai parameter agar bisa diuji dengan tanggal tetap.
Langkah 8, Kurs asinkron. Buat ambilKurs(kode) yang mensimulasikan jaringan: jeda acak 50-300 ms dan kadang gagal. Bungkus dengan denganTimeout(promise, ms) dan denganRetry(fn, { percobaan }). Untuk konversi ke USD, SGD, JPY, EUR, jalankan paralel dengan Promise.allSettled.
Langkah 9, Rangkai di main(). Function async main() mengisi data contoh, mencetak saldo, riwayat, laporan, tagihan, dan konversi. Panggil main().catch(...) di baris terakhir.
Langkah 10, Uji kasus error. Coba sengaja: jumlah negatif, tanggal "kemarin", pengeluaran melebihi saldo, kode kurs yang tidak ada. Pastikan semua menghasilkan pesan yang jelas, bukan crash.
Kode kerangka
// app.js, Pengelola Keuangan Pribadi (Proyek Akhir Fase 1)
// Jalankan: node app.js
// ===== Utilitas format (dibuat sekali, dipakai berkali-kali) =====
const TZ = "Asia/Jakarta";
const fmtRupiah = new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0 });
const fmtTanggal = new Intl.DateTimeFormat("id-ID", { day: "2-digit", month: "short", year: "numeric", timeZone: TZ });
const fmtBulan = new Intl.DateTimeFormat("id-ID", { month: "long", year: "numeric", timeZone: TZ });
const fmtKunci = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", timeZone: TZ });
const formatRupiah = (n) => fmtRupiah.format(n);
const formatTanggal = (d) => fmtTanggal.format(d);
const kunciBulanWIB = (d) => fmtKunci.format(d); // → "2026-10"
const tunggu = (ms) => new Promise((r) => setTimeout(r, ms));
// ===== Hierarki error =====
class KeuanganError extends Error {
constructor(message, options) {
super(message, options);
this.name = this.constructor.name;
}
}
class ValidasiError extends KeuanganError {
constructor(field, message) {
super(message);
this.field = field;
}
}
class SaldoTidakCukupError extends KeuanganError {
constructor(saldo, diminta) {
super(`Saldo ${formatRupiah(saldo)} tidak cukup untuk pengeluaran ${formatRupiah(diminta)}`);
this.kekurangan = diminta - saldo;
}
}
// ===== Model =====
class Transaksi {
static #berikutnya = 1;
constructor({ jenis, jumlah, kategori = "lainnya", keterangan = "", tanggal = new Date() }) {
if (!["masuk", "keluar"].includes(jenis)) throw new ValidasiError("jenis", `Jenis "${jenis}" tidak dikenal`);
if (!Number.isFinite(jumlah) || jumlah <= 0) throw new ValidasiError("jumlah", "Jumlah harus angka positif");
const tgl = new Date(tanggal);
if (Number.isNaN(tgl.getTime())) throw new ValidasiError("tanggal", `Tanggal tidak valid: ${tanggal}`);
this.id = Transaksi.#berikutnya++;
Object.assign(this, { jenis, jumlah, kategori: kategori.toLowerCase(), keterangan, tanggal: tgl });
Object.freeze(this); // transaksi yang tercatat tidak boleh diubah diam-diam
}
get nilaiBertanda() {
return this.jenis === "masuk" ? this.jumlah : -this.jumlah;
}
toString() {
const tanda = this.jenis === "masuk" ? "+" : "-";
return `#${this.id} ${formatTanggal(this.tanggal)} ${tanda}${formatRupiah(this.jumlah)} [${this.kategori}] ${this.keterangan}`;
}
}
class Dompet {
#transaksi = [];
constructor(pemilik) {
this.pemilik = pemilik;
}
get saldo() {
return this.#transaksi.reduce((total, t) => total + t.nilaiBertanda, 0);
}
tambah(data) {
const t = new Transaksi(data);
if (t.jenis === "keluar" && t.jumlah > this.saldo) {
throw new SaldoTidakCukupError(this.saldo, t.jumlah);
}
this.#transaksi.push(t);
return t;
}
hapus(id) {
const i = this.#transaksi.findIndex((t) => t.id === id);
if (i === -1) throw new ValidasiError("id", `Transaksi #${id} tidak ditemukan`);
// TODO: tolak penghapusan pemasukan jika membuat saldo negatif
return this.#transaksi.splice(i, 1)[0];
}
riwayat({ kategori, jenis } = {}) {
return this.#transaksi
.filter((t) => (!kategori || t.kategori === kategori) && (!jenis || t.jenis === jenis))
.toSorted((a, b) => b.tanggal - a.tanggal); // toSorted: salinan terurut, array asli aman
}
laporanBulan(kunci) {
const bulanIni = this.#transaksi.filter((t) => kunciBulanWIB(t.tanggal) === kunci);
const jumlahkan = (jenis) => bulanIni.filter((t) => t.jenis === jenis).reduce((s, t) => s + t.jumlah, 0);
const masuk = jumlahkan("masuk");
const keluar = jumlahkan("keluar");
const perKategori = bulanIni
.filter((t) => t.jenis === "keluar")
.reduce((acc, { kategori, jumlah }) => {
acc[kategori] = (acc[kategori] ?? 0) + jumlah;
return acc;
}, {});
const rincian = Object.entries(perKategori)
.sort(([, a], [, b]) => b - a)
.map(([kategori, total]) => ({
kategori,
total: formatRupiah(total),
persen: `${((total / keluar) * 100).toFixed(1)}%`,
}));
return { kunci, masuk, keluar, selisih: masuk - keluar, jumlahTransaksi: bulanIni.length, rincian };
}
}
// ===== Waktu: tagihan =====
const MS_HARI = 86_400_000;
function selisihHari(dari, ke) {
const a = Date.UTC(dari.getFullYear(), dari.getMonth(), dari.getDate());
const b = Date.UTC(ke.getFullYear(), ke.getMonth(), ke.getDate());
return Math.round((b - a) / MS_HARI);
}
function statusTagihan({ nama, jumlah, jatuhTempo }, hariIni) {
const n = selisihHari(hariIni, jatuhTempo);
const status = n < 0 ? `TERLAMBAT ${-n} hari` : n === 0 ? "jatuh tempo HARI INI" : `${n} hari lagi`;
return { nama, jumlah: formatRupiah(jumlah), jatuhTempo: formatTanggal(jatuhTempo), status };
}
// ===== Async: kurs (simulasi API) =====
const KURS_SERVER = { USD: 16250, SGD: 12480, JPY: 107.5 }; // Rupiah per 1 unit (angka contoh)
let panggilanKurs = 0;
async function ambilKurs(kode) {
panggilanKurs++;
await tunggu(50 + (panggilanKurs % 3) * 100); // simulasi jaringan: 50-250 ms
if (panggilanKurs === 2) throw new KeuanganError("Jaringan putus sesaat"); // gangguan sementara
if (!(kode in KURS_SERVER)) throw new ValidasiError("kode", `Kurs ${kode} tidak tersedia`);
return KURS_SERVER[kode];
}
function denganTimeout(promise, ms) {
let id;
const batas = new Promise((_, reject) => {
id = setTimeout(() => reject(new KeuanganError(`Waktu habis setelah ${ms} ms`)), ms);
});
return Promise.race([promise, batas]).finally(() => clearTimeout(id));
}
async function denganRetry(fn, { percobaan = 3, jedaAwal = 100 } = {}) {
for (let ke = 1; ; ke++) {
try {
return await fn();
} catch (err) {
if (err instanceof ValidasiError || ke >= percobaan) throw err; // error permanen: jangan ulang
await tunggu(jedaAwal * 2 ** (ke - 1));
}
}
}
async function konversiSaldo(saldo, daftarKode) {
const hasil = await Promise.allSettled(
daftarKode.map((kode) => denganRetry(() => denganTimeout(ambilKurs(kode), 1000))),
);
return hasil.map((h, i) => ({
mataUang: daftarKode[i],
nilai: h.status === "fulfilled" ? (saldo / h.value).toFixed(2) : "-",
keterangan: h.status === "fulfilled" ? `kurs ${h.value}` : h.reason.message,
}));
}
// ===== Program utama =====
async function main() {
const dompet = new Dompet("Sari");
const data = [
{ jenis: "masuk", jumlah: 8_000_000, kategori: "Gaji", keterangan: "Gaji Oktober", tanggal: "2026-10-01T09:00:00+07:00" },
{ jenis: "keluar", jumlah: 1_500_000, kategori: "Kos", keterangan: "Sewa kamar", tanggal: "2026-10-02T10:00:00+07:00" },
{ jenis: "keluar", jumlah: 85_000, kategori: "Makan", keterangan: "Belanja sayur", tanggal: "2026-10-05T07:30:00+07:00" },
{ jenis: "keluar", jumlah: 350_000, kategori: "Transport", keterangan: "Isi saldo e-money", tanggal: "2026-10-08T18:00:00+07:00" },
{ jenis: "keluar", jumlah: 420_000, kategori: "Makan", keterangan: "Makan bersama tim", tanggal: "2026-10-31T23:30:00+07:00" },
{ jenis: "keluar", jumlah: 200_000, kategori: "Internet", keterangan: "Paket data", tanggal: "2026-11-01T00:30:00+07:00" },
];
data.forEach((d) => dompet.tambah(d));
// Uji error yang disengaja
const uji = [
{ jenis: "keluar", jumlah: -5 },
{ jenis: "masuk", jumlah: 1000, tanggal: "kemarin" },
{ jenis: "keluar", jumlah: 99_000_000, kategori: "Gadget" },
];
console.group("Uji validasi");
for (const u of uji) {
try {
dompet.tambah(u);
} catch (err) {
if (!(err instanceof KeuanganError)) throw err;
console.log(`${err.name}: ${err.message}`);
}
}
console.groupEnd();
console.log(`\nSaldo ${dompet.pemilik}: ${formatRupiah(dompet.saldo)}`);
console.log("\nRiwayat pengeluaran makan:");
dompet.riwayat({ kategori: "makan" }).forEach((t) => console.log(" ", String(t)));
const lap = dompet.laporanBulan("2026-10");
console.log(`\nLaporan ${fmtBulan.format(new Date("2026-10-15T00:00:00+07:00"))} (${lap.jumlahTransaksi} transaksi)`);
console.log(`Masuk ${formatRupiah(lap.masuk)} | Keluar ${formatRupiah(lap.keluar)} | Selisih ${formatRupiah(lap.selisih)}`);
console.table(lap.rincian);
const hariIni = new Date(2026, 9, 23);
const tagihan = [
{ nama: "Listrik", jumlah: 275_000, jatuhTempo: new Date(2026, 9, 20) },
{ nama: "Cicilan HP", jumlah: 650_000, jatuhTempo: new Date(2026, 9, 23) },
{ nama: "BPJS", jumlah: 150_000, jatuhTempo: new Date(2026, 10, 10) },
];
console.log("\nPengingat tagihan:");
console.table(tagihan.map((t) => statusTagihan(t, hariIni)));
console.log("\nMengambil kurs...");
console.time("konversi");
console.table(await konversiSaldo(dompet.saldo, ["USD", "SGD", "JPY", "EUR"]));
console.timeEnd("konversi");
}
main().catch((err) => console.error("Terjadi error tak terduga:", err));
Contoh output (bagian utama):
$ node app.js
Uji validasi
ValidasiError: Jumlah harus angka positif
ValidasiError: Tanggal tidak valid: kemarin
SaldoTidakCukupError: Saldo Rp 5.445.000 tidak cukup untuk pengeluaran Rp 99.000.000
Saldo Sari: Rp 5.445.000
Riwayat pengeluaran makan:
#5 31 Okt 2026 -Rp 420.000 [makan] Makan bersama tim
#3 05 Okt 2026 -Rp 85.000 [makan] Belanja sayur
Laporan Oktober 2026 (5 transaksi)
Masuk Rp 8.000.000 | Keluar Rp 2.355.000 | Selisih Rp 5.645.000
(tabel: kos 63.7%, makan 21.4%, transport 14.9%)
Pengingat tagihan:
(tabel: Listrik TERLAMBAT 3 hari, Cicilan HP jatuh tempo HARI INI, BPJS 18 hari lagi)
Mengambil kurs...
(tabel: USD 335.08, SGD 436.30, JPY 50651.16, EUR "-" Kurs EUR tidak tersedia)
konversi: 253ms (bervariasi)
Perhatikan tiga detail yang membuktikan konsep bekerja. Pertama, transaksi 23.30 WIB tanggal 31 Oktober masuk laporan Oktober, sedangkan 00.30 WIB tanggal 1 November tidak, berkat kunciBulanWIB. Kedua, salah satu panggilan kurs sengaja gagal sementara ("Jaringan putus sesaat") lalu berhasil lewat retry. Ketiga, kurs EUR gagal permanen tetapi tidak menggagalkan tiga kurs lainnya karena allSettled. Angka kurs di kode adalah angka contoh, bukan kurs resmi.
Kriteria lulus
- [ ]
node app.jsberjalan tanpa crash dan tanpa unhandled rejection. - [ ]
TransaksidanDompetmemakaiclass, minimal satu private field#, satu getter, dan satustatic. - [ ] Ada hierarki custom error (
KeuanganError→ turunan) dan penanganan per jenis memakaiinstanceof. - [ ] Input salah (jumlah negatif, tanggal tidak valid, jenis tidak dikenal, saldo kurang) menghasilkan pesan jelas.
- [ ] Laporan bulanan mengelompokkan berdasarkan tanggal WIB, bukan
toISOString().slice(). - [ ] Rupiah dan tanggal tampil dalam format Indonesia memakai
Intl. - [ ] Status tagihan benar untuk kasus terlambat, hari ini, dan beberapa hari lagi.
- [ ] Pengambilan kurs memakai async/await, timeout, retry (tidak me-retry error permanen), dan
Promise.allSettled. - [ ]
riwayat()mengembalikan salinan; mengubah hasilnya tidak mengubah data di dompet. - [ ] Bagian
TODOdi methodhapus()sudah dilengkapi dan diuji.
Pengembangan lanjutan
- Simpan data. Di Node.js, simpan ke file JSON dengan
fs/promises(writeFile,readFile); di browser, gunakanlocalStorage. Perhatikan Date berubah jadi string ISO saat di-JSON.stringify, jadi ubah kembali saat memuat. - Kurs sungguhan. Ganti
ambilKursdenganfetchke API kurs publik pilihanmu. Cekres.ok, pasangAbortSignal.timeout(3000), dan simpan cache kurs selama 1 jam agar tidak memanggil API terus-menerus. - Anggaran per kategori. Tambahkan class
Anggarandan peringatan ketika pengeluaran suatu kategori melewati 80% batas bulanan. - Transaksi berulang. Buat transaksi otomatis setiap bulan (misalnya sewa kos tanggal 2) memakai fungsi
tambahBulandengan aturan jepit akhir bulan. - Antarmuka browser. Buat halaman HTML sederhana dengan form input, tabel riwayat, dan kolom pencarian ber-debounce.
- Pengujian otomatis. Tulis tes dengan modul bawaan
node:testdannode:assertuntukselisihHari,laporanBulan, dan validasiTransaksi.
Ringkasan
- Proyek ini menggabungkan class, private field, getter, static, dan custom error dalam satu domain nyata.
- Array method dan destructuring dipakai untuk filter, agregasi per kategori, dan pengurutan.
- Tanggal dikelompokkan berdasarkan WIB dan diformat dengan
Intluntuk pengguna Indonesia. - Kurs diambil secara asinkron dengan timeout, retry, dan
allSettledagar tahan terhadap kegagalan. - Semua error ditangani dengan pesan jelas, dan
main().catch()menjadi jaring pengaman terakhir.