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)