payroll/tests/test_lifecycle.py
Padreug e77f431d47 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
2026-08-31 23:02:45 +02:00

177 lines
5.6 KiB
Python

"""Contract lifecycle transitions.
Pure functions on the model, so no stubs needed. What matters here is which
moves are refused and — the one with money attached — what resuming does to
the paydays that fell while the contract was paused.
"""
from datetime import date
import pytest
from ..models import ContractStatus
from ..services import LifecycleError, cancel, pause, resume
from .conftest import make_contract
# --- pause -----------------------------------------------------------------
def test_pause_keeps_the_schedule_position():
contract = make_contract(periods_done=3)
pause(contract)
assert contract.status == ContractStatus.paused
assert contract.periods_done == 3
@pytest.mark.parametrize(
"status",
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
)
def test_only_an_active_contract_can_be_paused(status):
with pytest.raises(LifecycleError):
pause(make_contract(status=status))
# --- resume ----------------------------------------------------------------
def test_resume_skips_the_paydays_missed_while_paused():
"""A pause is a decision not to pay. Resuming in April on a monthly
contract paused in January must not fire three transfers."""
contract = make_contract(
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
)
resume(contract, today=date(2026, 4, 10))
assert contract.status == ContractStatus.active
assert contract.periods_done == 3 # Jan/Feb/Mar written off
# 15 April is still ahead, so it is the next one paid.
def test_resume_with_catch_up_pays_the_missed_paydays():
contract = make_contract(
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
)
resume(contract, today=date(2026, 4, 10), catch_up=True)
assert contract.periods_done == 0 # the backlog is left for the scheduler
def test_resume_does_not_skip_a_payday_falling_today():
contract = make_contract(
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
)
resume(contract, today=date(2026, 1, 15))
assert contract.periods_done == 0
def test_resuming_past_the_end_completes_the_contract():
contract = make_contract(
start_date="2026-01-15",
total_periods=2,
periods_done=0,
status=ContractStatus.paused,
)
resume(contract, today=date(2027, 1, 1))
assert contract.status == ContractStatus.completed
@pytest.mark.parametrize(
"status",
[ContractStatus.active, ContractStatus.cancelled, ContractStatus.completed],
)
def test_only_a_paused_contract_can_be_resumed(status):
with pytest.raises(LifecycleError):
resume(make_contract(status=status), today=date(2026, 6, 1))
# --- cancel ----------------------------------------------------------------
@pytest.mark.parametrize("status", [ContractStatus.active, ContractStatus.paused])
def test_cancel_from_a_live_status(status):
contract = make_contract(status=status)
cancel(contract)
assert contract.status == ContractStatus.cancelled
@pytest.mark.parametrize("status", [ContractStatus.cancelled, ContractStatus.completed])
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