Skip to main content
BlogERP

ERP Payroll Amendment Closed Period: Koreksi Tanpa Edit

Cara mengoreksi payroll yang sudah paid tanpa mengubahnya: amendment record menghubungkan run lama ke revision draft, lengkap dengan alasan, pembuat, dan audit trail.

Matthews Wong

Software Engineer

Oktober 202610 menit baca
ERP Payroll Amendment Closed Period: Koreksi Tanpa Edit

Ringkasan Utama

Untuk mengoreksi payroll run yang sudah closed, jangan edit run-nya. Buat amendment record yang menghubungkan run paid ke revision run draft baru, dengan alasan wajib dan nama pembuat, lalu proses revision lewat alur approval biasa. Run asli tetap immutable, dan audit trail menunjukkan apa yang berubah serta alasannya.

Sebuah payroll run sudah berstatus paid, file bank sudah terkirim, lalu ada yang menemukan satu baris yang salah. Solusi yang menggoda adalah satu statement UPDATE pada run yang sudah closed. Cukup sepuluh detik, tetapi diam-diam payslip, file bank, dan database jadi tidak sepakat soal apa yang terjadi di bulan itu.

Tulisan ini menjelaskan cara saya memodelkan koreksi di modul payroll sebuah ERP yang saya bangun, yang pada case study ANCoraPRO di site ini dibahas di level AP dan AR. Semuanya soal desain: tabel, state, dan invariant. Saya memakai nama generik dan contoh kecil yang ditulis ulang, dan sengaja tidak ada angka pajak, tarif, atau gaji.

Run yang sudah paid itu bukti, bukan dokumen yang bisa diedit

Begitu uang keluar, payroll run bukan lagi dokumen kerja, melainkan catatan dari sesuatu yang sudah terjadi. Payslip diterbitkan darinya, disbursement dibuat darinya, dan finance mungkin sudah membukukannya. Kalau barisnya diedit sesudahnya, semua artefak itu menunjuk ke angka yang sudah tidak ada.

Koreksi harus berupa fakta baru, bukan fakta lama yang diubah. Itu aturan yang sama dengan jurnal pembalik di akuntansi, dan alasan yang sama mengapa post audit trail di site ini menekankan history yang append-only. Pertanyaannya tinggal bagaimana membuat fakta baru bisa ditemukan dari fakta lama.

State machine menentukan di mana temboknya

Payroll period saya punya lima state: draft, submitted, approved, paid, dan cancelled. Temboknya dijaga oleh code di status, bukan oleh komentar. Setiap transisi memeriksa state yang diharapkan, dan edit item ditolak kecuali period-nya draft. Aturan yang saya andalkan:

  • Hanya period draft yang itemnya boleh diedit, jadi run submitted, approved, atau paid otomatis read-only.
  • Hanya draft yang bisa di-submit, hanya run submitted yang bisa di-approve, dan hanya run approved yang bisa ditandai paid.
  • Menandai paid mengubah period dan semua item-nya menjadi paid dalam satu database transaction, sehingga tidak ada run yang setengah paid.
  • Amend hanya boleh dari paid. State lain ditolak, sehingga jalur amendment tidak pernah bisa dipakai untuk menghindari alur normal.

Akibatnya, fitur amendment tidak butuh pengecualian dari aturan immutability. Ia tidak mengedit run paid, ia membuat draft, dan draft adalah satu-satunya state tempat edit itu sah.

Data model: dua run dan satu baris penghubung

Amendment adalah payroll period kedua ditambah satu baris kecil yang mengikatnya ke yang pertama. Baris penghubung itu membawa tiga hal yang ditanyakan auditor: run mana yang dikoreksi, kenapa, dan siapa yang melakukannya. Berikut bentuknya, ditulis ulang dengan nama generik.

enum RunStatus { draft submitted approved paid cancelled }

model PayRun {
  id         String      @id @default(uuid())
  number     String      @unique        // "RUN-2026-05"; a revision: "RUN-2026-05-AMEND-1"
  year       Int
  month      Int
  label      String?                    // non-null on a revision, so the key below differs
  status     RunStatus   @default(draft)
  lines      PayLine[]                  // onDelete: Cascade on the line side
  history    PayRunHistory[]

  revisionOf Amendment?  @relation("revision")  // set only on a revision run
  amendments Amendment[] @relation("original")  // every revision that corrects this run

  @@unique([year, month, label])
}

model Amendment {
  id          String   @id @default(uuid())
  originalId  String
  original    PayRun   @relation("original", fields: [originalId], references: [id])
  revisionId  String   @unique              // one revision run, exactly one amendment
  revision    PayRun   @relation("revision", fields: [revisionId], references: [id])
  reason      String                        // mandatory, validated in the service
  createdById String
  createdAt   DateTime @default(now())
  // No onDelete: a required relation defaults to Restrict, so an original that
  // has been amended cannot be deleted by accident.
}

Ada tiga detail penting. Foreign key revision bersifat unique, jadi satu revision run hanya milik satu amendment, sementara satu original bisa punya banyak amendment dari waktu ke waktu. Relasi ke original tidak memakai cascade, dan Prisma memberi default Restrict pada onDelete untuk relasi wajib, sehingga original yang punya amendment tidak bisa terhapus tanpa sengaja. Lalu revision memakai year dan month yang sama, yang bentrok dengan composite unique key, jadi ia membawa label yang berbeda.

Poin terakhir ada kerumitan di sisi database. Dokumentasi PostgreSQL menyebut bahwa secara default dua nilai NULL tidak dianggap sama dalam perbandingan unique. Run reguler dengan label NULL karena itu tidak terlindungi oleh composite key tersebut saja, sebab itu revision mengisi label non-null dan saya juga menyimpan run number unique terpisah.

Beri revision run number sendiri yang dibentuk dari original, misalnya nomor original ditambah suffix AMEND dan counter. Orang yang membaca mutasi bank atau tiket jadi bisa melihat garis keturunannya tanpa membuka database.

Membuat amendment sebagai clone dari run paid

Langkah pembuatan adalah satu service method di balik permission-nya sendiri. Di controller saya, ia berada di belakang approve guard, bukan prepare guard, jadi orang yang boleh meng-approve payroll adalah orang yang boleh membukanya kembali sebagai revision. Ia memvalidasi tiga hal sebelum menulis apa pun: original ada, statusnya paid, dan alasan tidak kosong.

Setelah itu ia meng-clone setiap baris original ke run draft baru. Tiap baris hasil clone diberi warning marker, dalam kasus saya bernama AMENDMENT_CLONE, supaya reviewer yang membuka draft langsung tahu baris itu warisan dan masih perlu dicek. Total run dihitung ulang dari baris hasil clone, lalu satu baris history dan satu audit entry tier critical mencatat alasannya.

Berikut alurnya, disederhanakan. Penomoran membaca jumlah amendment yang ada lalu menambah satu. Dua staf yang berebut akan menghitung nomor yang sama, dan run number yang unique mengubah tabrakan itu menjadi respons conflict, bukan duplikat.

async function amendPaidRun(originalId: string, reason: string, actor: Actor) {
  const original = await db.payRun.findUnique({
    where: { id: originalId },
    include: { lines: true, amendments: true },
  });
  if (!original) throw new NotFoundException("Original run not found");
  if (original.status !== "paid") throw new BadRequestException("Only paid runs can be amended");
  if (!reason || reason.trim().length === 0) throw new BadRequestException("Reason is required");

  // Count + 1 is read-then-write. Two clerks can compute the same number, so the
  // unique run number is the real guard: P2002 is mapped to a 409, never a duplicate.
  const n = original.amendments.length + 1;
  const revision = await db.payRun.create({
    data: {
      number: original.number + "-AMEND-" + n,
      year: original.year,
      month: original.month,
      label: "AMEND" + n,
      status: "draft", // draft is the only state where editing a line is legal
      preparedById: actor.id,
    },
  });

  for (const line of original.lines) {
    await db.payLine.create({
      data: { ...copyAmountColumns(line), runId: revision.id, warnings: ["AMENDMENT_CLONE"] },
    });
  }
  await refreshRunTotals(revision.id);
  await db.amendment.create({ data: { originalId, revisionId: revision.id, reason, createdById: actor.id } });
  await db.payRunHistory.create({ data: { runId: revision.id, action: "amendment_created", actorId: actor.id, comments: reason } });
  await audit.log({ tier: "CRITICAL", entityType: "Amendment", description: "Amended " + original.number + ": " + reason });
}

Kenapa full clone, dan biaya alternatifnya

Saya memilih full clone dari run sebagai revision. Itu keputusan desain, bukan hasil pengukuran: saya tidak mem-benchmark alternatifnya, dan tabel di bawah adalah penalaran saya tentang apa yang dilakukan tiap opsi pada run yang closed dan pada pertanyaan audit.

PendekatanNasib run yang closedBisa menjawab apa yang berubah dan kenapaBiaya utama
Edit run paid langsungTertimpa, payslip dan file bank jadi tidak cocok dengannyaHanya jika ada log terpisah dan tidak ada yang melewatinyaMenghancurkan bukti yang nanti dibutuhkan
Reverse dan rebook (run negatif)Tidak disentuh, run kedua membatalkannyaYa, tapi harus membaca dua run untuk melihat efek nettoDua approval untuk satu koreksi, melelahkan bagi approver
Delta run (hanya baris yang berubah)Tidak disentuhYa, delta persis sama dengan perubahannyaSetiap report harus tahu menambahkan delta ke original
Full clone sebagai revision draft (yang saya bangun)Tidak disentuh, dihubungkan lewat baris amendmentYa: alasan, pembuat, dan perbandingan baris per barisReport yang menjumlahkan run bisa menghitung satu bulan dua kali kecuali mengikuti link

Saya memilih clone karena reviewer melihat payroll run yang lengkap dan biasa, dan bisa memakai layar, rantai approval, dan export yang sama dengan run lain. Harganya ada di baris terakhir tabel, dan itu harga yang nyata.

Kegagalan yang dicegah desain ini, dan yang masih tersisa

Tanpa baris penghubung, kegagalan yang paling mungkin bukan niat jahat, melainkan kesunyian. Seseorang memperbaiki satu baris, angka barunya benar, dan enam bulan kemudian tidak ada yang bisa menjelaskan kenapa run Maret berbeda dari yang dibayarkan ke karyawan. Ini penalaran saya tentang failure mode, bukan insiden yang saya laporkan. Baris amendment mengubah pertanyaan itu menjadi sekadar lookup.

// Wrong: a one-line fix on a closed run. Payslips and the bank file keep the old figure.
await db.payLine.update({ where: { id: lineId }, data: { netAmount: corrected } });

// Right: the run is never touched. A draft revision is created, linked, reviewed and approved.
await amendPaidRun(originalRunId, "Reason a reviewer can read in a year", actor);

// Right (what I would ship): the same steps in ONE transaction, so there is no orphan draft.
await db.$transaction(async (tx) => {
  const revision = await tx.payRun.create({ data: revisionData });
  await tx.payLine.createMany({ data: clonedLines(original.lines, revision.id) });
  await tx.amendment.create({ data: { originalId, revisionId: revision.id, reason, createdById: actor.id } });
});

Kegagalan yang masih tersisa ada di implementasi saya sendiri. Method create menjalankan insert period, insert tiap baris, update total, baris penghubung, dan baris history sebagai statement terpisah. Sementara mark-paid di service yang sama memakai database transaction. Kalau proses mati setelah period terbentuk tetapi sebelum baris penghubung, hasilnya draft run yang tidak ditunjuk oleh apa pun.

Buat revision run, baris hasil clone, dan baris penghubung dalam satu transaction. Draft yatim yang tampak seperti run biasa tapi tanpa baris amendment lebih buruk daripada tanpa amendment, karena ia tidak membawa alasan maupun original.

Keterbatasan dan apa yang akan saya ubah

Unit spec untuk jalur amendment mencakup dua perilaku: original yang tidak paid ditolak, dan yang paid di-clone dengan marker di tiap baris. Spec itu tidak mencakup alasan kosong, jalur conflict, maupun report. Ini celah yang akan saya tutup, plus pertanyaan yang tidak dijawab oleh code:

  • Bungkus pembuatan dalam satu transaction seperti di atas, dan tambah test yang mematikan proses di antara statement.
  • Clone menyalin nominal yang tersimpan di tiap baris tetapi tidak menyalin link ke record cicilan pinjaman, jadi state cicilan tetap di baris original. Putuskan dengan sengaja apa yang harus dilakukan revision pada potongan yang sudah lunas.
  • Clone dimulai tanpa disbursement request. Apakah revision dibayar penuh atau hanya selisihnya terhadap original adalah pertanyaan kebijakan yang tidak dijawab oleh schema, dan membayarnya penuh karena salah paham berarti membayar dua kali.
  • Setiap report yang menjumlahkan run per bulan butuh aturan eksplisit: pakai revision terbaru, atau original ditambah delta-nya, tetapi jangan keduanya.

Saya belum mengukur apa pun di bawah beban, dan jalur amendment secara alami jarang dipakai. Anggap desain ini kuat di invariant dan belum terbukti di volume.

Checklist Senin untuk modul payroll Anda

  1. Daftar semua state run Anda dan pastikan edit item ditolak di setiap state kecuali draft.
  2. Tambah tabel amendment berisi run original, revision run yang unique, alasan wajib, pembuat, dan timestamp.
  3. Batasi amend hanya untuk run paid, dan taruh di belakang permission approve, bukan permission prepare.
  4. Clone ke draft dalam satu transaction, tandai tiap baris hasil clone, lalu hitung ulang total.
  5. Tulis ke history run sekaligus audit log, dan sertakan alasan di keduanya.
  6. Putuskan secara tertulis bagaimana amendment dibayar dan bagaimana report menghindari penghitungan bulan yang sama dua kali.

Bacaan terkait di site ini

Payroll run yang sudah closed adalah catatan, jadi koreksilah dengan menambah revision yang terhubung, bukan dengan mengubahnya. Baris amendment hanya butuh satu tabel kecil dan satu transaction, dan ia mengubah siapa yang mengubah ini dan kenapa dari investigasi menjadi query.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Bagaimana cara mengoreksi payroll run yang sudah paid?

Jangan edit run yang sudah paid. Buat amendment: payroll run draft baru hasil clone dari run paid, ditambah baris penghubung yang menyimpan run asli, alasan, dan pembuatnya. Perbaiki baris di draft lalu proses lewat langkah submit dan approve biasa.

Kenapa payroll period yang sudah closed harus immutable?

Payslip, disbursement bank, dan jurnal akuntansi semuanya dibuat dari run itu. Kalau diedit sesudahnya, artefak tersebut menunjuk ke angka yang sudah tidak ada. Run yang immutable menjaga satu catatan tepercaya tentang apa yang benar-benar dibayarkan.

Apa bedanya payroll amendment dengan reversal?

Reversal membukukan run kedua yang negatif untuk membatalkan yang pertama, dan angka yang benar datang dari run ketiga. Amendment menghubungkan satu revision draft ke original dan membandingkannya baris per baris. Keduanya tidak menyentuh run yang closed, tetapi amendment hanya butuh satu approval untuk satu koreksi.

Field apa saja yang harus ada di tabel payroll amendment?

Minimal: id run original, id revision run yang unique, alasan wajib, pembuat, dan timestamp pembuatan. Jadikan id revision unique agar satu revision hanya milik satu amendment, dan hindari cascade delete pada relasi ke original.

Apakah report bisa menghitung satu bulan dua kali jika ada amendment?

Bisa, jika report hanya menjumlahkan semua run dalam sebulan dan amendment adalah full clone dari original. Tentukan aturan sejak awal: pakai hanya revision terbaru, atau original ditambah delta, dan pastikan semua report mengikuti link amendment.