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