Skip to main content
BlogERP

ERP Payroll Amendment Closed Period: Fix It Without Editing

How to correct a paid payroll run without touching it: an amendment record links the closed run to a draft revision, with a reason, a creator and an audit trail.

Matthews Wong

Software Engineer

October 202610 min read
ERP Payroll Amendment Closed Period: Fix It Without Editing

Key Takeaway

To correct a closed payroll run, never edit it. Add an amendment record that links the paid run to a new draft revision run, with a mandatory reason and the creator, then push the revision through the normal approval flow. The original stays immutable, and the audit trail shows exactly what changed and why.

A payroll run reaches the paid state, the bank file has gone out, and then someone finds a line that was wrong. The tempting fix is one UPDATE statement on the closed run. It takes ten seconds, and it also quietly makes the payslip, the bank file and the database disagree about what happened in that month.

This post describes how I modelled the correction in the payroll module of an ERP I built, which the site's ANCoraPRO case study covers at the AP and AR level. Everything here is design: tables, states and invariants. I use generic names and small rewritten examples, and the post carries no tax, rate or pay figures on purpose.

A paid run is evidence, not a document you can edit

Once money has left, a payroll run stops being a working document and becomes a record of something that happened. Payslips were issued from it, a disbursement was built from it, and finance may have booked it. If you edit the row afterwards, every one of those artefacts now points at numbers that no longer exist.

The correction has to be a new fact, not a changed old one. That is the same rule an accountant follows with a reversing entry, and the same reason the audit trail post on this site insists on append-only history. The question is only how to make the new fact discoverable from the old one.

The state machine decides where the wall is

My payroll period has five states: draft, submitted, approved, paid and cancelled. The code enforces the wall in the status, not in a comment. Each transition checks the state it expects, and editing an item is refused unless the period is draft. These are the rules I rely on:

  • Only a draft period can have its items edited, so a submitted, approved or paid run is read-only by construction.
  • Only a draft can be submitted, only a submitted run can be approved, and only an approved run can be marked paid.
  • Marking paid flips the period and all its items to paid inside one database transaction, so there is no half-paid run.
  • Amending is allowed only from paid. Every other state is rejected, so the amendment path can never be used to sidestep the normal flow.

The consequence is that the amendment feature needs no special exception to the immutability rule. It does not edit the paid run, it creates a draft, and draft is the one state where editing is legal.

An amendment is a second payroll period plus a small row that ties it to the first. The link row carries the three things an auditor asks for: which run is being corrected, why, and who did it. Here is the shape, rewritten with generic names.

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.
}

Three details matter. The revision foreign key is unique, so a revision run belongs to exactly one amendment, while one original can have many amendments over time. The relations to the original have no cascade, and Prisma defaults a required relation to Restrict on delete, so an original that has an amendment cannot be deleted by accident. And the revision reuses the same year and month, which collides with the composite unique key, so it carries a distinct label.

That last point has a database wrinkle. The PostgreSQL documentation says two NULL values are not considered equal in a unique comparison by default. A regular run with a NULL label would therefore not be protected by that composite key alone, which is why the revision sets a non-null label and why I also keep a separate unique run number.

Give the revision its own run number built from the original, such as the original number plus an AMEND suffix and a counter. A human reading a bank statement or a ticket can then see the lineage without opening the database.

Creating the amendment as a clone of the paid run

The create step is one service method behind its own permission. In my controller it sits behind the approve guard, not the prepare guard, so the person who can approve payroll is the person who can reopen it as a revision. It validates three things before it writes anything: the original exists, it is paid, and the reason is not blank.

Then it clones every line of the original into the new draft run. Every cloned line gets a warning marker, in my case a value called AMENDMENT_CLONE, so a reviewer opening the draft sees at a glance that these lines are inherited and still need checking. Run totals are recomputed from the cloned lines, and a history row and a critical-tier audit entry record the reason.

Here is the flow, simplified. The numbering reads the count of existing amendments and adds one. Two clerks racing would compute the same number, and the unique run number turns the collision into a conflict response instead of a duplicate.

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 });
}

Why a full clone, and what the alternatives cost

I chose a full clone of the run as the revision. That is a design choice, not a measurement: I did not benchmark the alternatives, and the table below is my reasoning about what each one does to the closed run and to the audit question.

ApproachWhat happens to the closed runCan you answer what changed and whyMain cost
Edit the paid run in placeOverwritten, payslips and bank file now disagree with itOnly if a separate log was kept and nobody skipped itDestroys the evidence you will need later
Reverse and rebook (negative run)Untouched, a second run cancels itYes, but you read two runs to see the net effectTwo approvals for one correction, noisy for the person approving
Delta run (only the changed lines)UntouchedYes, the delta is exactly the changeEvery report must know to add deltas to the original
Full clone as a draft revision (what I built)Untouched, linked by an amendment rowYes: reason, creator and a line-by-line comparisonReports that sum runs can count a month twice unless they follow the link

I picked the clone because the reviewer sees a complete, ordinary payroll run and can use the same screens, the same approval chain and the same export as any other run. The price is the last row of the table, and it is a real one.

The failure this design prevents, and the one it still has

Without a link row, the most likely failure is not malice, it is silence. Someone corrects a line, the corrected amount is right, and six months later nobody can say why the March run differs from what the employee was paid. This is my reasoning about the failure mode, not an incident I am reporting. The amendment row turns that question into a 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 } });
});

The failure the design still has is in my own implementation. The create method issues the period insert, each line insert, the totals update, the link row and the history row as separate statements. Mark-paid, in the same service, uses a database transaction. If the process dies after the period exists but before the link row, you get a draft run that nothing points back to.

Create the revision run, its cloned lines and the link row in one transaction. An orphan draft that looks like a normal run but has no amendment row is worse than no amendment, because it carries no reason and no original.

Limits and what I would do differently

The unit spec for the amendment path covers two behaviours: a non-paid original is rejected, and a paid one is cloned with the marker on each line. It does not cover a blank reason, the conflict path or reports. These are the gaps I would close, and the questions the code does not answer for me:

  • Wrap creation in a single transaction, as above, and add a test that kills the process between statements.
  • The clone copies the amounts stored on each line but not the link to loan installment records, so installment state stays on the original line. Decide on purpose what a revision should do with deductions that were already settled.
  • The clone starts with no disbursement request attached. Whether the revision is paid in full or only as a difference against the original is a policy question the schema does not answer, and paying it in full by mistake would pay twice.
  • Any report that sums runs for a month needs an explicit rule: use the latest revision, or the original plus its deltas, but never both.

I have not measured any of this under load, and the amendment path is exercised rarely by nature. Treat the design as sound on invariants and unproven on volume.

A Monday checklist for your own payroll module

  1. List your run states and confirm that item edits are rejected in every state except draft.
  2. Add an amendment table with the original run, a unique revision run, a mandatory reason, the creator and a timestamp.
  3. Restrict amending to paid runs, and put it behind the approve permission rather than the prepare permission.
  4. Clone into a draft inside one transaction, mark each cloned line, and recompute the totals.
  5. Write to both a run history and your audit log, and put the reason in both.
  6. Decide, in writing, how the amendment is paid and how reports avoid counting the month twice.

Related reading on this site

A closed payroll run is a record, so correct it by adding a linked revision rather than by changing it. The amendment row costs one small table and one transaction, and it turns who changed this and why from an investigation into a query.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

How do I correct a payroll run that is already paid?

Do not edit the paid run. Create an amendment: a new draft payroll run cloned from the paid one, plus a link row that stores the original run, the reason and the creator. Fix the lines in the draft and send it through the normal submit and approve steps.

Why should a closed payroll period be immutable?

Payslips, the bank disbursement and accounting entries were all produced from that run. Editing it afterwards leaves those artefacts pointing at numbers that no longer exist. An immutable run keeps one trustworthy record of what was actually paid.

What is the difference between a payroll amendment and a reversal?

A reversal books a second, negative run that cancels the first, and the correct figures come from a third run. An amendment links one draft revision to the original and compares them line by line. Both leave the closed run untouched, but the amendment needs only one approval for one correction.

What fields should a payroll amendment table have?

At minimum: the original run id, a unique revision run id, a mandatory reason, the creator and a created-at timestamp. Make the revision id unique so a revision belongs to one amendment, and avoid cascade deletes on the relation to the original.

Can reports double count a month when amendments exist?

Yes, if a report simply sums every run for a month and the amendment is a full clone of the original. Decide a rule up front: use only the latest revision, or the original plus deltas, and make every report follow the amendment link.