Fix double-counted leave on withdrawal-during-edit, and 500s on a stale leave-type id - #44
Merged
karlitschek merged 2 commits intoAug 12, 2026
Conversation
… 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>
karlitschek
deleted the
fix/noid/withdrawal-during-edit-and-unknown-type
branch
August 12, 2026 13:10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:PENDING,supersedes_id → R1.WITHDRAWAL_PENDING. No check fired.retireSuperseded()required the original to beAPPROVED, foundWITHDRAWAL_PENDING, and returnednull. R1 was never retired.R1 then contributed 5 days to
pendingwhile R2 contributed its full days toused— 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 invariantRequestService'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 messageassertNoPendingEdit()already gives — cancel the edit first, then withdraw. AndretireSuperseded()now treatsWITHDRAWAL_PENDINGas still in force alongsideAPPROVED, 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()throwsDoesNotExistException, which is not anAbsenceException, soApiControllerTraitnever 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:
EntitlementService::setForEmployee()typeIdEntitlementService::bulkSet()BalanceService::ensureEntitlement()tryit sits inThe two in
EntitlementServicedid the same find-then-check, so they are now oneassertCountingType()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_PENDINGinstead ofCANCELLED, the type ones raisingDoesNotExistExceptioninstead ofValidationException.php -lclean. No frontend change.🤖 Generated with Claude Code