Skip to content

Fix double-counted leave on withdrawal-during-edit, and 500s on a stale leave-type id - #44

Merged
karlitschek merged 2 commits into
mainfrom
fix/noid/withdrawal-during-edit-and-unknown-type
Aug 12, 2026
Merged

Fix double-counted leave on withdrawal-during-edit, and 500s on a stale leave-type id#44
karlitschek merged 2 commits into
mainfrom
fix/noid/withdrawal-during-edit-and-unknown-type

Conversation

@karlitschek

Copy link
Copy Markdown
Member

Two bugs found while reading through the workflow code. One commit each; both regression tests were confirmed to fail without their fix.

1. A withdrawal during a pending edit counted the same leave twice

Only one edit may be in flight per approved request, and createSuperseding() enforces that. Nothing enforced the other half — the two rules did not compose:

  1. R1 is approved (say 5 days). The employee edits it → R2 PENDING, supersedes_id → R1.
  2. The employee then withdraws R1 → WITHDRAWAL_PENDING. No check fired.
  3. The manager approves R2 → retireSuperseded() required the original to be APPROVED, found WITHDRAWAL_PENDING, and returned null. R1 was never retired.

R1 then contributed 5 days to pending while R2 contributed its full days to used — the same leave counted twice. BalanceService's netting could not help: it only nets while the superseding request is itself pending.

Worse, declining the withdrawal afterwards returned R1 to APPROVED, leaving two overlapping approved requests on the same dates, both counted and both on the calendar — exactly the invariant RequestService's class docblock documents itself as protecting.

Fixed at both ends. cancel() now refuses to start a withdrawal while an edit is pending, reusing the message assertNoPendingEdit() already gives — cancel the edit first, then withdraw. And retireSuperseded() now treats WITHDRAWAL_PENDING as still in force alongside APPROVED, so a pair written before this guard still retires cleanly instead of double-counting forever.

Retiring an original now also dismisses its notifications: it is closed, so a withdrawal request against it would otherwise sit in the manager's list offering to withdraw leave that no longer exists.

2. A stale leave-type id answered 500 instead of 422

LeaveTypeMapper::find() throws DoesNotExistException, which is not an AbsenceException, so ApiControllerTrait never recognised it — an HR form left open while somebody else removed the type answered "An unexpected error occurred" with a 500 and an error-level log line.

Three call sites were unguarded:

Site Reachable when
EntitlementService::setForEmployee() HR passes a stale typeId
EntitlementService::bulkSet() same, via the bulk endpoint
BalanceService::ensureEntitlement() runs inside the missing-entitlement handler, so the exception it raised was never caught by the try it sits in

The two in EntitlementService did the same find-then-check, so they are now one assertCountingType() helper. resolveType() already handled this correctly; these now match it.

Testing

Full suite: 161 tests, 441 assertions, 0 failures (4 new). Each new test was verified to fail against the unfixed code — the withdrawal one leaving the original at WITHDRAWAL_PENDING instead of CANCELLED, the type ones raising DoesNotExistException instead of ValidationException. php -l clean. No frontend change.

🤖 Generated with Claude Code

Frank Karlitschek and others added 2 commits August 12, 2026 15:06
… twice

Only one edit may be in flight per approved request, and createSuperseding()
enforces that. Nothing enforced the other half: an employee could edit approved
leave and then withdraw the original, and the two rules did not compose.

The edit excludes the original from its overlap check as part of the supersedes
chain, and retireSuperseded() only retired an original that was still APPROVED.
So with the original moved to WITHDRAWAL_PENDING, approving the edit walked past
it: the edit became APPROVED while the original stayed in force. The same leave
was then counted twice — as used by the edit and as pending by the original —
and BalanceService's netting could not help, because it only nets while the
superseding request is itself pending. Declining the withdrawal afterwards left
two overlapping APPROVED requests on the same dates, which is exactly the
invariant this class documents itself as protecting.

Fixed at both ends. cancel() now refuses to start a withdrawal while an edit is
pending, with the message assertNoPendingEdit() already gives: cancel the edit
first, then withdraw. And retireSuperseded() treats WITHDRAWAL_PENDING as still
in force alongside APPROVED, so a pair written before this guard still retires
cleanly rather than silently double-counting forever.

Retiring an original also dismisses its notifications now: it is closed, so a
withdrawal request against it would otherwise sit in the manager's list offering
to withdraw leave that no longer exists.

Both regression tests fail without the change and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LeaveTypeMapper::find() throws DoesNotExistException, which is not an
AbsenceException, so ApiControllerTrait never recognised it: an HR form left
open while somebody else removed the type answered "An unexpected error
occurred" with a 500 and an error-level log line, instead of saying what was
wrong.

Three call sites were unguarded. The two in EntitlementService — reachable from
the HR UI with a stale type id, via both the single and bulk endpoints — did the
same find-then-check, so they are now one assertCountingType() helper. The third,
in BalanceService::ensureEntitlement(), is the subtle one: it runs *inside* the
handler for the missing-entitlement case, so the exception it raises was never
caught by the try it sits in.

resolveType() already did this correctly; these now match it.

Both regression tests fail without the change and pass with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nextcloud-command nextcloud-command added the AI assisted This PR contains AI-assisted commits label Aug 12, 2026
@karlitschek
karlitschek merged commit 879e65a into main Aug 12, 2026
31 of 35 checks passed
@karlitschek
karlitschek deleted the fix/noid/withdrawal-during-edit-and-unknown-type branch August 12, 2026 13:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted This PR contains AI-assisted commits

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants