diff --git a/docs/operations.md b/docs/operations.md index a8e06da..b0ad578 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -105,3 +105,32 @@ lock exists for the off-cycle paths that can land mid-tick. This is per-process. Two LNbits processes sharing one database would not be serialised by it — payroll assumes the single-writer deployment LNbits itself assumes. + +## Previewing a schedule + +`POST /payroll/api/v1/schedule/preview` draws a calendar from loose terms +(`start_date`, `frequency`, `total_periods`) — no employee or wallet +required, because the schedule is usually the thing worth sanity-checking +first, and a mistyped start date is cheapest to fix before anything is +saved. `GET /payroll/api/v1/contracts/{id}/schedule` does the same for a +live contract, from its current position. + +Both return `ends_on`, the contract's last payday. "12 monthly payments +from 15 Jan" is much easier to check against "ends 15 Dec" than against a +list of twelve dates. + +## Paying off-cycle + +`POST /payroll/api/v1/contracts/{id}/pay-now` settles the contract's next +period immediately, whatever the calendar says, and consumes it. + +One endpoint covers both "run it now" (don't wait for the tick) and "pay it +early", because they are the same operation — they differ only in whether +today happens to be the payday. It bypasses the back-dated skip: that guard +exists to stop a *new* contract firing surprise back-pay, and an operator +explicitly asking to pay a period is not a surprise. + +It is recorded in the ledger like any other payout, with the early payment +noted in `detail`, and the ledger row is returned even when the payout +failed — a manual payout that did not land is exactly when you want to know +why. diff --git a/models.py b/models.py index 00edf83..15ccaac 100644 --- a/models.py +++ b/models.py @@ -244,3 +244,41 @@ class Payout(BaseModel): @property def amount_sat(self) -> int | None: return None if self.amount_msat is None else self.amount_msat // 1000 + + +# --------------------------------------------------------------------------- +# Schedule preview +# --------------------------------------------------------------------------- + + +class SchedulePreviewRequest(BaseModel): + """Enough of a contract to draw its calendar, with none of its identity. + + Deliberately not a `CreateContract`: previewing must work before an + employee or a wallet has been chosen, since the schedule is usually the + thing the operator wants to sanity-check first. + """ + + start_date: str # YYYY-MM-DD + frequency: Frequency = Frequency.monthly + total_periods: int | None = None + count: int = 12 # how many paydays to draw + + @validator("start_date") + def _valid_date(cls, v: str) -> str: + datetime.strptime(v, "%Y-%m-%d") + return v + + @validator("count") + def _sane_count(cls, v: int) -> int: + return max(1, min(v, 120)) + + +class SchedulePreview(BaseModel): + paydays: list[str] # YYYY-MM-DD, in order + total_periods: int | None = None + # The contract's last payday, or None for an open-ended one. Worth + # surfacing on its own: "12 monthly payments from 15 Jan" is much easier + # to check against "ends 15 Dec" than against a list. + ends_on: str | None = None + truncated: bool = False # more paydays exist than were drawn diff --git a/services.py b/services.py index 3d4ede0..f167e8c 100644 --- a/services.py +++ b/services.py @@ -101,18 +101,46 @@ def next_payday(contract: Contract) -> date | None: ) -def upcoming_paydays(contract: Contract, count: int) -> list[date]: - """The next `count` paydays, truncated by any period cap.""" - start = parse_start_date(contract) - last = contract.total_periods if contract.total_periods is not None else None - indices = range(contract.periods_done, contract.periods_done + count) +def paydays_from( + start: date, + frequency: Frequency, + first_index: int, + count: int, + total_periods: int | None = None, +) -> list[date]: + """`count` paydays starting at period `first_index`, truncated by any cap. + + Takes loose parameters rather than a Contract so the operator can preview + a schedule *before* the contract exists — which is the point at which a + mistyped start date or the wrong frequency is cheap to fix. + """ return [ - occurrence_on(start, contract.frequency, i) - for i in indices - if last is None or i < last + occurrence_on(start, frequency, i) + for i in range(first_index, first_index + count) + if total_periods is None or i < total_periods ] +def upcoming_paydays(contract: Contract, count: int) -> list[date]: + """The next `count` paydays for a live contract, from its position.""" + return paydays_from( + parse_start_date(contract), + contract.frequency, + contract.periods_done, + count, + contract.total_periods, + ) + + +def final_payday( + start: date, frequency: Frequency, total_periods: int | None +) -> date | None: + """When an open-ended contract would end — None, by definition.""" + if total_periods is None: + return None + return occurrence_on(start, frequency, total_periods - 1) + + def due_period_indices(contract: Contract, today: date) -> list[int]: """Every period index that is payable as of `today`, in order. @@ -516,3 +544,55 @@ def cancel(contract: Contract) -> Contract: raise LifecycleError(f"Contract is already {contract.status.value}.") contract.status = ContractStatus.cancelled return contract + + +# --------------------------------------------------------------------------- +# Off-cycle payout +# --------------------------------------------------------------------------- + + +async def pay_now(contract: Contract) -> Payout: + """Settle the contract's next period immediately, whatever the calendar + says. + + The operator's manual override: "run it now" when waiting five minutes + for the tick is not acceptable, and "pay it early" when a payday needs to + land before it is due. Both are the same operation — the next period + settles and is consumed — so there is one endpoint rather than two that + differ only in whether today happens to be the payday. + + It calls `pay_period` directly rather than going through `_settle`, + because `_settle`'s back-dated skip exists to stop a *new* contract + firing surprise back-pay. An operator explicitly asking to pay a period + is not a surprise. + """ + async with _lock_for(contract.id): + fresh = await crud.get_contract(contract.id) + if not fresh: + raise LifecycleError("Contract not found.") + contract = fresh + + if contract.status != ContractStatus.active: + raise LifecycleError( + f"Only an active contract can be paid (is {contract.status.value})." + ) + remaining = contract.periods_remaining + if remaining is not None and remaining <= 0: + raise LifecycleError("Contract has no periods left to pay.") + + index = contract.periods_done + payday = occurrence_on(parse_start_date(contract), contract.frequency, index) + today = datetime.now(timezone.utc).date() + + outcome = await pay_period(contract, index, payday) + if payday > today and outcome.status == PayoutStatus.paid: + outcome.detail = f"paid early on {today.isoformat()} (due {payday})" + + payout = await _record(contract, outcome) + if outcome.consumed: + contract.periods_done = index + 1 + _maybe_complete(contract) + else: + _maybe_pause_after_repeated_failure(contract, payout) + await crud.update_contract(contract) + return payout diff --git a/tests/test_payout.py b/tests/test_payout.py index 9ceaea1..defff71 100644 --- a/tests/test_payout.py +++ b/tests/test_payout.py @@ -13,7 +13,13 @@ from datetime import date import pytest from ..models import ContractStatus, PayoutStatus -from ..services import MAX_PERIOD_ATTEMPTS, PeriodOutcome, run_due_periods +from ..services import ( + MAX_PERIOD_ATTEMPTS, + LifecycleError, + PeriodOutcome, + pay_now, + run_due_periods, +) from .conftest import make_contract @@ -210,3 +216,44 @@ def test_a_failure_under_the_cap_leaves_the_contract_active(payroll_stub): assert payroll_stub.contract.status == ContractStatus.active assert payroll_stub.ledger[-1].attempt == MAX_PERIOD_ATTEMPTS - 1 + + +# --- off-cycle payout ------------------------------------------------------ + + +def test_pay_now_settles_the_next_period_before_its_payday(payroll_stub): + payroll_stub.contract = make_contract( + start_date="2026-12-01", created_at="2026-01-01" + ) + + payout = asyncio.run(pay_now(payroll_stub.contract)) + + assert payout.status == PayoutStatus.paid + assert payout.period_index == 0 + assert payroll_stub.contract.periods_done == 1 + + +def test_pay_now_ignores_the_back_dated_skip(payroll_stub): + """`_settle` skips back-dated periods to stop a new contract firing + surprise back-pay. An operator explicitly asking to pay is not a + surprise, so pay_now goes straight to the transfer.""" + payroll_stub.contract = make_contract( + start_date="2026-01-15", created_at="2026-05-01", backfill=False + ) + + payout = asyncio.run(pay_now(payroll_stub.contract)) + + assert payout.status == PayoutStatus.paid + assert payroll_stub.attempted == [0] + + +def test_pay_now_refuses_a_paused_contract(payroll_stub): + payroll_stub.contract = make_contract(status=ContractStatus.paused) + with pytest.raises(LifecycleError): + asyncio.run(pay_now(payroll_stub.contract)) + + +def test_pay_now_refuses_an_exhausted_contract(payroll_stub): + payroll_stub.contract = make_contract(total_periods=2, periods_done=2) + with pytest.raises(LifecycleError): + asyncio.run(pay_now(payroll_stub.contract)) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index c795a2a..b37f4b9 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -14,8 +14,10 @@ from ..services import ( MAX_CATCH_UP_PERIODS, add_months, due_period_indices, + final_payday, next_payday, occurrence_on, + paydays_from, upcoming_paydays, ) from .conftest import make_contract @@ -147,3 +149,36 @@ def test_upcoming_paydays_are_truncated_by_the_period_cap(): def test_upcoming_paydays_are_unbounded_for_open_ended_contracts(): contract = make_contract(start_date="2026-01-15", total_periods=None) assert len(upcoming_paydays(contract, 24)) == 24 + + +# --- preview from loose terms ---------------------------------------------- + + +def test_paydays_from_draws_a_window_of_the_schedule(): + assert paydays_from(date(2026, 1, 31), Frequency.monthly, 0, 4) == [ + date(2026, 1, 31), + date(2026, 2, 28), + date(2026, 3, 31), + date(2026, 4, 30), + ] + + +def test_paydays_from_starts_at_the_given_index(): + assert paydays_from(date(2026, 1, 15), Frequency.weekly, 2, 2) == [ + date(2026, 1, 29), + date(2026, 2, 5), + ] + + +def test_paydays_from_stops_at_the_period_cap(): + drawn = paydays_from(date(2026, 1, 15), Frequency.monthly, 0, 10, total_periods=3) + assert len(drawn) == 3 + + +def test_final_payday_is_the_last_period_not_one_past_it(): + """12 monthly payments from 15 Jan end on 15 Dec, not 15 Jan next year.""" + assert final_payday(date(2026, 1, 15), Frequency.monthly, 12) == date(2026, 12, 15) + + +def test_open_ended_contracts_have_no_final_payday(): + assert final_payday(date(2026, 1, 15), Frequency.monthly, None) is None diff --git a/views_api.py b/views_api.py index 5d977f0..5d5db04 100644 --- a/views_api.py +++ b/views_api.py @@ -12,6 +12,7 @@ has for their own wallets. It is not instance-admin. (See the auth table in the workspace CLAUDE.md.) """ +from datetime import date, datetime from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException @@ -27,6 +28,8 @@ from .models import ( DirectoryUser, Payout, PayoutStatus, + SchedulePreview, + SchedulePreviewRequest, UpdateContract, ) @@ -243,3 +246,75 @@ async def api_list_payouts( return await crud.get_payouts( contract_id=contract_id, status=status, limit=min(limit, 1000) ) + + +# --------------------------------------------------------------------------- +# Schedule preview and off-cycle payout +# --------------------------------------------------------------------------- + + +def _preview( + start: date, frequency, total_periods: int | None, first_index: int, count: int +) -> SchedulePreview: + paydays = services.paydays_from(start, frequency, first_index, count, total_periods) + end = services.final_payday(start, frequency, total_periods) + return SchedulePreview( + paydays=[d.isoformat() for d in paydays], + total_periods=total_periods, + ends_on=end.isoformat() if end else None, + truncated=total_periods is None or first_index + count < total_periods, + ) + + +@payroll_api_router.post("/api/v1/schedule/preview") +async def api_preview_schedule(data: SchedulePreviewRequest) -> SchedulePreview: + """Draw a contract's calendar before it exists. + + The cheapest moment to notice a mistyped start date or the wrong + frequency is before anything is saved, so this takes loose terms rather + than a full contract — no employee or wallet needed. + """ + return _preview( + datetime.strptime(data.start_date, "%Y-%m-%d").date(), + data.frequency, + data.total_periods, + 0, + data.count, + ) + + +@payroll_api_router.get("/api/v1/contracts/{contract_id}/schedule") +async def api_contract_schedule(contract_id: str, count: int = 12) -> SchedulePreview: + """The paydays still ahead of a live contract, from its current position.""" + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + return _preview( + services.parse_start_date(contract), + contract.frequency, + contract.total_periods, + contract.periods_done, + max(1, min(count, 120)), + ) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now") +async def api_pay_now(contract_id: str) -> Payout: + """Settle the next period immediately, whatever the calendar says. + + Covers both "run it now" (do not wait for the tick) and "pay it early", + which are the same operation — the next period settles and is consumed — + so they are one endpoint rather than two that differ only in whether + today happens to be the payday. + + Recorded in the ledger like any other payout, with the early-payment + noted in its detail. Returns the ledger row, including a failed one: + the caller wants to know *why* a manual payout did not land. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + try: + return await services.pay_now(contract) + except services.LifecycleError as exc: + raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc