kodingup.camp

Beranda/JavaScript Mastery: Prototype, Class, Waktu, Error & Async

Proyek Akhir Fase 1

Modul 15 dari 155 menit bacaPengelola Keuangan Pribadi

Isi modul

    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

    1. Model data dengan class. Transaksi (id otomatis, jenis "masuk"/"keluar", jumlah, kategori, keterangan, tanggal) dengan validasi di constructor.
    2. Custom error. Minimal ValidasiError dan SaldoTidakCukupError, keduanya turunan dari KeuanganError extends Error.
    3. Dompet. Class Dompet dengan private field #transaksi, getter saldo, method tambah(), hapus(id), dan riwayat() yang mengembalikan salinan. Pengeluaran yang membuat saldo negatif ditolak.
    4. 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.
    5. Format Indonesia. Rupiah memakai Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR" }), tanggal memakai Intl.DateTimeFormat("id-ID").
    6. Pengingat tagihan. Daftar tagihan { nama, jumlah, jatuhTempo } dengan status "x hari lagi", "hari ini", atau "terlambat x hari" memakai selisih hari kalender.
    7. Kurs asinkron. Function ambilKurs(kode) yang mengembalikan Promise, dibungkus timeout dan retry. Konversi saldo ke beberapa mata uang memakai Promise.allSettled sehingga satu kurs gagal tidak menggagalkan semuanya.
    8. 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

    Pengembangan lanjutan

    1. Simpan data. Di Node.js, simpan ke file JSON dengan fs/promises (writeFile, readFile); di browser, gunakan localStorage. Perhatikan Date berubah jadi string ISO saat di-JSON.stringify, jadi ubah kembali saat memuat.
    2. Kurs sungguhan. Ganti ambilKurs dengan fetch ke API kurs publik pilihanmu. Cek res.ok, pasang AbortSignal.timeout(3000), dan simpan cache kurs selama 1 jam agar tidak memanggil API terus-menerus.
    3. Anggaran per kategori. Tambahkan class Anggaran dan peringatan ketika pengeluaran suatu kategori melewati 80% batas bulanan.
    4. Transaksi berulang. Buat transaksi otomatis setiap bulan (misalnya sewa kos tanggal 2) memakai fungsi tambahBulan dengan aturan jepit akhir bulan.
    5. Antarmuka browser. Buat halaman HTML sederhana dengan form input, tabel riwayat, dan kolom pencarian ber-debounce.
    6. Pengujian otomatis. Tulis tes dengan modul bawaan node:test dan node:assert untuk selisihHari, laporanBulan, dan validasi Transaksi.

    Ringkasan

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.