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

@ -105,3 +105,73 @@ def test_cancel_from_a_live_status(status):
def test_terminal_contracts_cannot_be_cancelled_again(status):
with pytest.raises(LifecycleError):
cancel(make_contract(status=status))
# --- resuming an automatic pause -------------------------------------------
# Payroll pausing a contract because it could not pay, and an operator
# pausing one because they chose not to, mean opposite things. Sharing a
# recovery path silently writes off money that is still owed.
def _auto_paused(**kw):
contract = make_contract(status=ContractStatus.paused, **kw)
contract.paused_reason = "period 0 (2026-01-15) failed 5 times: no rate"
return contract
def test_resuming_an_auto_paused_contract_keeps_the_backlog():
"""The operator fixed the cause; those paydays are still owed."""
contract = _auto_paused(start_date="2026-01-15", periods_done=0)
resume(contract, today=date(2026, 4, 10))
assert contract.status == ContractStatus.active
assert contract.periods_done == 0 # nothing written off
assert contract.paused_reason == ""
def test_resuming_an_operator_pause_still_writes_off():
contract = make_contract(
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
)
resume(contract, today=date(2026, 4, 10))
assert contract.periods_done == 3
def test_an_explicit_choice_overrides_the_inference():
contract = _auto_paused(start_date="2026-01-15", periods_done=0)
resume(contract, today=date(2026, 4, 10), catch_up=False)
assert contract.periods_done == 3
def test_pausing_by_hand_clears_an_earlier_automatic_reason():
"""Otherwise a contract that once auto-paused would keep catching up
forever, even after a deliberate pause."""
contract = _auto_paused(periods_done=2)
contract.status = ContractStatus.active
pause(contract)
assert contract.paused_reason == ""
def test_the_pause_reason_names_the_period_and_cause():
from ..models import Payout, PayoutStatus
from ..services import MAX_PERIOD_ATTEMPTS, _maybe_pause_after_repeated_failure
contract = make_contract()
payout = Payout(
id="p1",
contract_id=contract.id,
period_index=3,
payday="2026-04-15",
status=PayoutStatus.failed,
attempt=MAX_PERIOD_ATTEMPTS,
detail="no historical EUR rate available for 2026-04-15",
)
_maybe_pause_after_repeated_failure(contract, payout)
assert contract.status == ContractStatus.paused
assert "2026-04-15" in contract.paused_reason
assert "no historical EUR rate" in contract.paused_reason