feat: contract lifecycle — pause, resume, cancel
Beyond create and delete, a payroll line needs to be stoppable without being erased. Three transitions, expressed as pure functions on the model so the rules are testable without a DB, and mapped to 409 at the API boundary — a refused transition is a well-formed request that the contract's current state declines. The decision with money attached is what resume does about the paydays that fell while the contract was paused. It skips them: a pause is a decision not to pay, and resuming into an unannounced multi-period transfer is the opposite of what "resume" implies. `catch_up=true` opts into paying them, for a pause that was an operational hold rather than a call about the money. Cancel is now the way to stop a running payroll; DELETE stays as the "created it by mistake" escape hatch, and the docstrings say which is which, because deleting discards the schedule position and the record that the contract ever existed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
99f2131474
commit
95bfa86f79
3 changed files with 238 additions and 2 deletions
107
tests/test_lifecycle.py
Normal file
107
tests/test_lifecycle.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""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))
|
||||
Loading…
Add table
Add a link
Reference in a new issue