2024 — 2026 · Team Lead

Sistem Payroll 800+ Karyawan

Engine payroll dengan PPh 21 TER, BPJS, dan PP 58/2023. Payroll run idempoten, audit trail 10 entitas, setiap angka bisa dilacak sampai ke sumbernya.

  • NestJS
  • TypeScript
  • MySQL
  • AWS SQS
  • React.js
  • Docker
PeranTeam Lead — arsitektur, implementasi, scoping ke klien
Skala800+ karyawan, rilis produksi Juni 2026
DomainPPh 21 metode TER, BPJS, PP 58/2023
StackNestJS, MySQL, AWS SQS (LocalStack di lokal), React, Docker
Tim2 proyek, 2 engineer

Masalahnya

Di payroll tidak ada bug yang sifatnya cuma tampilan. Angka yang salah bukan laporan bug — itu gaji yang tidak sampai, di tanggal orang harus bayar cicilan, ke orang yang tidak punya cara memeriksa hitungan saya.

Pertanyaan desainnya bukan "seberapa cepat sistem ini jalan", melainkan: bisakah setiap angka di slip gaji dijelaskan enam bulan kemudian, ke orang yang tidak punya alasan untuk percaya pada saya? Dari situ lahir tiga syarat:

  1. Bisa dilacak. Setiap angka bisa ditelusuri sampai ke data sumbernya.
  2. Idempoten. Payroll dijalankan dua kali tidak boleh membayar dua kali.
  3. Bisa dijelaskan tanpa saya. Sistem harus selamat dari serah terima.

Perjalanannya

2024, sistem payroll untuk perusahaan sendiri — internal, skala kecil. Jalan benar selama setahun penuh. 2025, klien butuh masalah yang sama diselesaikan untuk 800+ karyawan. Setahun tanpa insiden itu yang jadi alasan melanjutkan sistem yang sudah ada, bukan menulis ulang dari nol. Yang rilis Juni 2026 adalah kelanjutannya, bukan tulisan ulang.

Lompatan skalanya yang mengubah cara kerja teknisnya: payroll 40 orang masih bisa dicek manual, 800 orang tidak — jadi sistemnya sendiri yang harus bisa mempertanggungjawabkan hasilnya.

Keputusan arsitektur

Scope resolution: satu aturan, lima tingkat

Aturan kompensasi tidak pernah datar. Tunjangan makan bisa berlaku untuk semua orang, di-override untuk satu divisi, di-override lagi untuk satu posisi, dan terakhir untuk satu karyawan yang menegosiasikannya.

Kalau tiap jenis aturan punya tabel dan logikanya sendiri, logika override ditulis ulang tiap ada komponen baru. Satu scope resolution engine, prioritas tetap:

employee > division > department > position > employment type

Semua komponen kompensasi lewat urutan yang sama. Menambah komponen jadi perubahan data, bukan kode — dan urutan resolusinya cuma satu hal yang perlu dijelaskan, bukan satu penjelasan per aturan.

Konsekuensinya: resolusi butuh rantai lookup, bukan sekali baca, dibayar sekali per karyawan per run. Saya ambil karena alternatifnya adalah logika override tersebar di banyak file — jenis bug yang tidak ketemu walau file mana pun dibaca satu per satu.

Kehadiran itu diturunkan, bukan diinput

Bagian paling berantakan justru yang paling tidak diduga: menentukan apakah seseorang hari ini masuk kerja. Tidak ada yang mengetikkan itu — datanya diturunkan dari absen mesin fingerprint, dan data mentahnya sendiri tidak menjawab pertanyaannya.

Jam vs. timetable shiftDengan surat izinTanpa surat izin
KurangGaji prorataTidak dapat gaji
Terpenuhi / lebihGaji penuhGaji penuh

Lalu kalendernya ikut campur:

  • Hari libur nasional dihitung hari kerja untuk karyawan bulanan, tidak untuk karyawan harian.
  • Cuti bersama membayar penuh karyawan harian selama jatah cuti tahunannya masih ada — tidak membayar kalau habis.
  • Absen di hari libur dengan timetable lembur dapat upah lembur, bukan gaji harian — hari itu memang bukan hari kerjanya.
  • Empat level jabatan teratas dikecualikan dari semuanya; gajinya sama sekali tidak bergantung pada fingerprint.

Itu bukan rumus — itu matriks jenis karyawan × sisa cuti × timetable × jenis hari libur, dan di situlah sistem payroll diam-diam menghilangkan uang orang. Saya tidak menyederhanakannya karena ini bukan rumit, ini detail — dua masalah berbeda, dan menyederhanakan detail berarti memutuskan kasus pinggiran siapa yang boleh diabaikan. Tiap cabang dibuat eksplisit dan bernama, supaya hari yang dipersoalkan bisa ditelusuri balik ke aturan spesifiknya.

Idempotency, karena retry itu pasti terjadi

Satu run menyentuh ratusan record dalam beberapa menit — cukup lama untuk koneksi putus, worker restart, atau operator klik dua kali. Semuanya berakhir sama: operasinya diulang.

  • Payroll run dan kasbon idempoten — request berulang menghasilkan hasil yang sama, bukan pembayaran kedua.
  • Run dikendalikan state machine, bukan flag boolean. Run yang mati di tengah punya status yang jelas namanya dan bisa dilanjutkan — bukan status abu-abu yang harus ditebak engineer jam 2 pagi.
  • Pemrosesan dipecah lewat antrean AWS SQS (di lokal dicerminkan lewat LocalStack, jadi kontrak antreannya identik di dev maupun produksi). Di 800+ karyawan, satu transaksi sinkron itu risiko timeout sekaligus kegagalan yang menyeret semuanya.
  • Kegagalan per karyawan, bukan per run. Payslip yang gagal di-retry otomatis; run ulang hanya memproses karyawan yang masih gagal. Tanpa itu, memulihkan tiga payslip yang salah berarti memproses ulang 800 orang.

Saya juga menemukan dan memperbaiki satu race condition: dua request kasbon bersamaan bisa sama-sama lolos pengecekan saldo sebelum salah satunya commit — bug yang tidak pernah muncul waktu testing dan selalu muncul di produksi.

Data hulu yang bukan milik kita

Insentif dihitung dari hasil produksi di ERP terpisah: produktivitas mesin, tonase, waste, ketepatan pengiriman. Data itu bukan milik saya, tidak datang saat diminta, dan tidak selalu benar.

Jadi ditarik terjadwal ke tabel staging sebelum periode payroll, bukan saat run berjalan. Payroll run tidak pernah menggantung menunggu pihak ketiga hidup; data yang salah kelihatan sebelum jadi slip gaji dan bisa ditarik ulang tanpa menyentuh payroll; kaitan antara angka dan record asalnya tetap ada, jadi masih bisa dijelaskan nanti. Tarifnya sendiri pemetaan, bukan perhitungan — hasil produksi dipetakan ke tabel tarif berjenjang, jadi negosiasi tarif baru itu perubahan data, bukan tiket ke saya.

Tidak ada yang final sebelum bisa direview manusia

Dua hal praktis tidak bisa dibatalkan: payroll run yang sudah diposting, dan THR — pembayaran wajib yang dihitung dari rentang dua belas bulan. Keduanya diberi tahap review sebelum final.

THR dihitung dulu ke rekap tersendiri yang direview dan disetujui bagian keuangan sebelum masuk ke slip gaji siapa pun. Payroll run dikunci prasyarat yang dicek sistem sendiri: absensi lengkap, data hulu sudah ditarik, cuti, lembur, dan surat sanksi sudah disetujui. Dulu itu pengetahuan lisan siapa pun yang menjalankan payroll bulan lalu; sekarang checklist yang dievaluasi dan ditampilkan sistem.

Mode kegagalan yang dicegah spesifik dan mahal: run yang selesai sukses di atas data belum lengkap. Tidak ada error apa pun — angkanya saja yang salah, dan tidak ada yang tahu sampai orang-orang dibayar.

Aturan pajak sebagai data, bukan kode

Sistem ini mengikuti PPh 21 metode TER, BPJS, dan PP 58/2023. Aturan pajak berubah, dan kalau di-hardcode, tiap revisi pemerintah berarti deploy ulang — instrumen yang buruk untuk perubahan yang seharusnya diputuskan bagian keuangan, bukan engineer.

Konstantanya bisa diubah dari halaman admin, dengan nilai resmi yang di-seed di awal. Logika perhitungan urusan engineer, angkanya urusan keuangan. Audit trail mencatat siapa mengubah konstanta apa dan kapan — yang bikin pembagian tugas ini aman, bukan cuma praktis.

Dua perbaikan yang muncul dari data asli

  • Take-home negatif ditolak, bukan ditulis. Gabungan potongan bisa melebihi gaji kotor — secara hitungan valid, tidak masuk akal untuk dibayarkan. Run-nya berhenti dan mengangkat kasusnya ke manusia.
  • Prorate pakai hari kerja, bukan hari kalender. Prorate kalender diam-diam merugikan orang yang masuk di bulan dengan akhir pekan menumpuk di awal.

Security

Sistem ini menyimpan data gaji dan NIK, jadi melewati satu tahap hardening khusus:

KontrolYang dicegah
Visibilitas per baris, bukan per halamanAdmin HRD melihat setara dan di bawahnya saja — bukan soal "boleh buka halaman", tapi "baris mana yang boleh dikembalikan"
Immutability run yang sudah postedRun selesai itu catatan historis, aplikasi tidak boleh mengubah sejarah
JWT revocation saat terminasiSesi karyawan yang keluar mati saat itu juga, bukan menunggu token kedaluwarsa
Rate limitingDi endpoint login dan ekspor data

Satu bug yang layak disebut sendiri: kebocoran passwordHash lewat relasi yang ikut ter-eager load — satu relasi user terserialisasi utuh ke response API yang tidak berhubungan. Ketemu karena saya baca response-nya, bukan baca kodenya.

Audit trail untuk 10 entitas

Bukan logging per fitur, tapi satu mekanisme generik untuk 10 entitas — satu skema, satu tempat mencari, tiap perubahan tercatat lengkap: pelaku, waktu, nilai sebelum dan sesudah. Bagian ini yang bikin "setiap angka bisa dilacak" jadi kenyataan: angka yang dipersoalkan enam bulan kemudian bisa ditelusuri balik ke data dan keputusan yang menghasilkannya.

Hasil

  • Rilis Juni 2026, produksi untuk 800+ karyawan.
  • Sampai sekarang masih saya yang rawat — untuk sistem payroll, itu sendiri sebuah verdict: sistem yang buruk bukan diganti diam-diam, tapi diganti dengan ribut.
  • Sistem internal 2024 tetap jadi basis arsitekturnya, sekarang dipakai lintas proyek klien.
  • Dikerjakan sambil memimpin 2 proyek dan 2 engineer, termasuk scoping kebutuhan langsung dengan klien.

Yang akan saya lakukan berbeda

Scope resolution engine ini abstraksi yang tepat, tapi saya membangunnya sebelum ada konsumen kedua — menebak hierarkinya, bukan menurunkannya dari kebutuhan nyata. Kebetulan tebakannya benar. Lain kali saya akan tahan dulu, biarkan kodenya duplikat satu siklus lagi, dan biarkan dua kasus nyata yang menentukan bentuk abstraksinya.