From ee10b2e04d27beab4968701d4d4a59e02409b535 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:51:20 +0200 Subject: [PATCH] feat: schedule preview and off-cycle payouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things an operator needs that the scheduler alone does not give them. Preview draws a contract's calendar from loose terms — start date, frequency, period count — with no employee or wallet required, because the schedule is what an operator wants to sanity-check first and a mistyped start date is cheapest to fix before anything is saved. The same shape is available for a live contract, from its current position. Both return `ends_on`: "12 monthly payments from 15 Jan" is far easier to verify against "ends 15 Dec" than against a list of twelve dates. pay-now settles the next period immediately and consumes it. Deliberately one endpoint rather than two: "run it now, don't wait for the tick" and "pay it early" are the same operation and 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 is not a surprise — and it takes the same per-contract lock as the scheduler, which is what that lock was added for. The ledger row is returned even on failure, since a manual payout that did not land is exactly when you want the reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- docs/operations.md | 29 +++++++++++++ models.py | 38 +++++++++++++++++ services.py | 96 ++++++++++++++++++++++++++++++++++++++---- tests/test_payout.py | 49 ++++++++++++++++++++- tests/test_schedule.py | 35 +++++++++++++++ views_api.py | 75 +++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 9 deletions(-) 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