fix: resuming an auto-paused contract no longer discards the backlog
Found by tracing what happens when a back-dated backfill contract cannot fetch a historical rate. The failure handling itself was fine — period 0 fails, the backlog halts so nothing settles out of order, five ledger rows record the reason, no money moves, and the contract auto-pauses once the retry budget is spent. The recovery was not. The operator fixes the cause (switches to a stated rate, or to current), clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently written off, contract back to looking healthy, employee never paid. The confirm dialog even asserted the missed paydays "are written off" — true of one kind of pause and a lie about the other. Two features colliding. "Do not backfill a deliberate pause" is right when the operator paused: the pause *was* the decision not to pay. It is wrong when payroll paused, because nobody decided anything — the money is still owed and the operator has just removed whatever blocked it. Contracts now carry `paused_reason`, set only when payroll pauses them and cleared by a deliberate pause. Resume infers from it, and an explicit `catch_up` still overrides either way. The console asks a different question for each, quoting the reason, and flags a payroll-paused contract in the table so the distinction is visible before anyone clicks. Verified end to end: five failing ticks leave periods_done at 0 and pause with "period 0 (2026-08-01) failed 5 times: no historical EUR rate available for 2026-08-01"; resuming after switching to a manual rate keeps the position at 0, and the next tick settles all seven owed periods. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
1584eb337f
commit
e77f431d47
8 changed files with 168 additions and 22 deletions
33
services.py
33
services.py
|
|
@ -555,6 +555,10 @@ def _maybe_pause_after_repeated_failure(contract: Contract, payout: Payout) -> N
|
|||
if payout.attempt < MAX_PERIOD_ATTEMPTS:
|
||||
return
|
||||
contract.status = ContractStatus.paused
|
||||
contract.paused_reason = (
|
||||
f"period {payout.period_index} ({payout.payday}) failed "
|
||||
f"{payout.attempt} times: {payout.detail}"
|
||||
)
|
||||
logger.error(
|
||||
f"payroll: contract {contract.id} paused after {payout.attempt} failed "
|
||||
f"attempts at period {payout.period_index} ({payout.payday}): "
|
||||
|
|
@ -651,29 +655,42 @@ def pause(contract: Contract) -> Contract:
|
|||
f"Only an active contract can be paused (is {contract.status.value})."
|
||||
)
|
||||
contract.status = ContractStatus.paused
|
||||
# A deliberate pause carries no reason, which is what makes resume treat
|
||||
# the missed paydays as written off rather than still owed.
|
||||
contract.paused_reason = ""
|
||||
return contract
|
||||
|
||||
|
||||
def resume(
|
||||
contract: Contract, today: date | None = None, catch_up: bool = False
|
||||
contract: Contract, today: date | None = None, catch_up: bool | None = None
|
||||
) -> Contract:
|
||||
"""Put a paused contract back to work.
|
||||
|
||||
By default the paydays that fell during the pause are *not* paid: a
|
||||
pause is a decision not to pay them, and resuming into a surprise
|
||||
multi-period transfer is the opposite of what an operator asking to
|
||||
resume expects. The contract's position is fast-forwarded to the next
|
||||
payday on or after today instead.
|
||||
What happens to the paydays that fell during the pause depends on who
|
||||
paused it, because the two cases mean opposite things:
|
||||
|
||||
`catch_up=True` opts into paying them, for the case where the pause was
|
||||
an operational hold rather than a decision about the money.
|
||||
* **The operator paused it.** Those paydays are written off — the pause
|
||||
*was* the decision not to pay them, and resuming into a surprise
|
||||
multi-period transfer is the opposite of what "resume" implies. The
|
||||
position fast-forwards to the next payday on or after today.
|
||||
* **Payroll paused it** — a period exhausted its retries, because the
|
||||
wallet was empty or the date could not be priced. Nobody decided
|
||||
anything, the money is still owed, and the operator has just fixed
|
||||
whatever blocked it. The backlog is kept and settles on the next tick.
|
||||
|
||||
Sharing one path between those two silently destroys money that is owed,
|
||||
so the inference is on `paused_reason`. `catch_up` overrides it.
|
||||
"""
|
||||
if contract.status != ContractStatus.paused:
|
||||
raise LifecycleError(
|
||||
f"Only a paused contract can be resumed (is {contract.status.value})."
|
||||
)
|
||||
|
||||
if catch_up is None:
|
||||
catch_up = bool(contract.paused_reason)
|
||||
|
||||
contract.status = ContractStatus.active
|
||||
contract.paused_reason = ""
|
||||
if not catch_up:
|
||||
today = today or _today()
|
||||
skipped_to = fast_forward_index(contract, today)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue