From 95bfa86f790807c3edf81cd07999f3be81b8ba98 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:47:07 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20contract=20lifecycle=20=E2=80=94=20paus?= =?UTF-8?q?e,=20resume,=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- services.py | 77 ++++++++++++++++++++++++++++- tests/test_lifecycle.py | 107 ++++++++++++++++++++++++++++++++++++++++ views_api.py | 56 ++++++++++++++++++++- 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 tests/test_lifecycle.py diff --git a/services.py b/services.py index 72c3376..d54df83 100644 --- a/services.py +++ b/services.py @@ -25,7 +25,7 @@ from lnbits.core.services import create_invoice, pay_invoice from loguru import logger from . import crud -from .models import Contract, ContractStatus, Frequency +from .models import TERMINAL_STATUSES, Contract, ContractStatus, Frequency # Frequencies that are an exact number of days: no calendar involved, so no # clamping is possible or needed. @@ -361,3 +361,78 @@ async def tick(today: date | None = None) -> None: await run_due_periods(contract, today) except Exception as exc: logger.error(f"payroll: contract {contract.id} tick failed: {exc}") + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +class LifecycleError(ValueError): + """An illegal status transition. Mapped to 409 at the API boundary.""" + + +def fast_forward_index(contract: Contract, today: date) -> int: + """The first period index whose payday is not already in the past.""" + start = parse_start_date(contract) + cap = contract.total_periods + index = contract.periods_done + while (cap is None or index < cap) and occurrence_on( + start, contract.frequency, index + ) < today: + index += 1 + return index + + +def pause(contract: Contract) -> Contract: + if contract.status != ContractStatus.active: + raise LifecycleError( + f"Only an active contract can be paused (is {contract.status.value})." + ) + contract.status = ContractStatus.paused + return contract + + +def resume( + contract: Contract, today: date | None = None, catch_up: bool = False +) -> Contract: + """Put a paused contract back to work. + + By default the paydays that fell during the pause are *not* paid: a + pause is a decision not to pay them, and resuming into a surprise + multi-period transfer is the opposite of what an operator asking to + resume expects. The contract's position is fast-forwarded to the next + payday on or after today instead. + + `catch_up=True` opts into paying them, for the case where the pause was + an operational hold rather than a decision about the money. + """ + if contract.status != ContractStatus.paused: + raise LifecycleError( + f"Only a paused contract can be resumed (is {contract.status.value})." + ) + + contract.status = ContractStatus.active + if not catch_up: + today = today or datetime.now(timezone.utc).date() + skipped_to = fast_forward_index(contract, today) + if skipped_to != contract.periods_done: + logger.info( + f"payroll: contract {contract.id} resumed, skipping " + f"{skipped_to - contract.periods_done} payday(s) missed while paused" + ) + contract.periods_done = skipped_to + _maybe_complete(contract) + return contract + + +def cancel(contract: Contract) -> Contract: + """Stop a contract for good, keeping the row and its history. + + This — not DELETE — is how a running payroll is stopped: deleting throws + away the schedule position and the record that the contract ever existed. + """ + if contract.status in TERMINAL_STATUSES: + raise LifecycleError(f"Contract is already {contract.status.value}.") + contract.status = ContractStatus.cancelled + return contract diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..c8ccd57 --- /dev/null +++ b/tests/test_lifecycle.py @@ -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)) diff --git a/views_api.py b/views_api.py index 76d87c1..9f6a83c 100644 --- a/views_api.py +++ b/views_api.py @@ -19,7 +19,7 @@ from lnbits.core.crud import get_wallet from lnbits.decorators import check_super_user from lnbits.utils.exchange_rates import allowed_currencies -from . import crud +from . import crud, services from .accounts import list_directory_users, owns_wallet from .models import Contract, CreateContract, DirectoryUser, UpdateContract @@ -161,3 +161,57 @@ async def api_delete_contract(contract_id: str) -> None: if not contract: raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") await crud.delete_contract(contract_id) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +async def _transition(contract_id: str, apply) -> Contract: + """Load a contract, apply a services-layer transition, persist it. + + The transitions themselves are pure functions on the model — they raise + LifecycleError for an illegal move, which becomes a 409 here rather than + a 400, because the request is well-formed and it is the contract's + current state that refuses it. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + try: + apply(contract) + except services.LifecycleError as exc: + raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc + return await crud.update_contract(contract) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/pause") +async def api_pause_contract(contract_id: str) -> Contract: + """Stop paying without losing the schedule position.""" + return await _transition(contract_id, services.pause) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/resume") +async def api_resume_contract(contract_id: str, catch_up: bool = False) -> Contract: + """Put a paused contract back to work. + + Paydays missed during the pause are skipped by default — a pause is a + decision not to pay them, and resuming into an unannounced multi-period + transfer is the opposite of what "resume" implies. `catch_up=true` pays + them, for a pause that was an operational hold rather than a decision + about the money. + """ + return await _transition( + contract_id, lambda c: services.resume(c, catch_up=catch_up) + ) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/cancel") +async def api_cancel_contract(contract_id: str) -> Contract: + """Stop a contract for good, keeping the row and its history. + + The right way to end a payroll line. DELETE is the "created it by + mistake" escape hatch and discards the record entirely. + """ + return await _transition(contract_id, services.cancel)