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
177 lines
5.6 KiB
Python
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
|