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:
Padreug 2026-08-31 23:02:45 +02:00
commit e77f431d47
8 changed files with 168 additions and 22 deletions

View file

@ -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)