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
56
views_api.py
56
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue