feat: schedule preview and off-cycle payouts

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
Padreug 2026-08-31 13:51:20 +02:00
commit ee10b2e04d
6 changed files with 313 additions and 9 deletions

View file

@ -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