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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue