payroll/views_api.py
Padreug e77f431d47 fix: resuming an auto-paused contract no longer discards the backlog
Found by tracing what happens when a back-dated backfill contract cannot
fetch a historical rate. The failure handling itself was fine — period 0
fails, the backlog halts so nothing settles out of order, five ledger rows
record the reason, no money moves, and the contract auto-pauses once the
retry budget is spent. The recovery was not.

The operator fixes the cause (switches to a stated rate, or to current),
clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently
written off, contract back to looking healthy, employee never paid. The
confirm dialog even asserted the missed paydays "are written off" — true of
one kind of pause and a lie about the other.

Two features colliding. "Do not backfill a deliberate pause" is right when
the operator paused: the pause *was* the decision not to pay. It is wrong
when payroll paused, because nobody decided anything — the money is still
owed and the operator has just removed whatever blocked it.

Contracts now carry `paused_reason`, set only when payroll pauses them and
cleared by a deliberate pause. Resume infers from it, and an explicit
`catch_up` still overrides either way. The console asks a different question
for each, quoting the reason, and flags a payroll-paused contract in the
table so the distinction is visible before anyone clicks.

Verified end to end: five failing ticks leave periods_done at 0 and pause
with "period 0 (2026-08-01) failed 5 times: no historical EUR rate
available for 2026-08-01"; resuming after switching to a manual rate keeps
the position at 0, and the next tick settles all seven owed periods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 23:02:45 +02:00

430 lines
16 KiB
Python

"""Payroll REST API.
Every route here is gated at the *router* level on `check_super_user`, not
per-endpoint. Payroll reads the account directory and moves money between
wallets the caller does not own, so the gate has to be instance-admin, and
putting it on the router means a new endpoint cannot be added ungated by
forgetting a decorator.
`require_admin_key` would be the wrong choice and is easy to reach for by
mistake: it authorises writes to the *caller's own* wallet, which any user
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, Response
from lnbits.core.crud import get_wallet
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_super_user, require_invoice_key
from lnbits.utils.exchange_rates import allowed_currencies
from . import crud, services
from .accounts import list_directory_users, owns_wallet
from .export import payouts_to_csv
from .models import (
Contract,
CreateContract,
DirectoryUser,
Payout,
PayoutStatus,
PricingMode,
SchedulePreview,
SchedulePreviewRequest,
UpdateContract,
)
payroll_api_router = APIRouter(dependencies=[Depends(check_super_user)])
# ---------------------------------------------------------------------------
# Directory
# ---------------------------------------------------------------------------
@payroll_api_router.get("/api/v1/users")
async def api_list_users(search: str | None = None) -> list[DirectoryUser]:
"""Accounts + their wallets, for the "select a user" picker."""
return await list_directory_users(search=search)
@payroll_api_router.get("/api/v1/currencies")
async def api_list_currencies() -> list[str]:
"""Currencies a contract may be denominated in. "sat" first because a
sat-denominated contract needs no FX at all."""
return ["sat", *allowed_currencies()]
# ---------------------------------------------------------------------------
# Contract validation
# ---------------------------------------------------------------------------
async def _validate_terms(data: CreateContract) -> str:
"""Check a proposed contract against the world outside its own row, and
return the employee's display name for the snapshot label.
The employee/wallet pair arrives from a form as two independent ids;
nothing further down the write path re-checks that they belong
together, so this is where a payout gets stopped from being aimed at
somebody else's wallet.
"""
users = {u.id: u for u in await list_directory_users()}
employee = users.get(data.employee_id)
if not employee:
raise HTTPException(HTTPStatus.NOT_FOUND, "Employee account not found.")
if not await owns_wallet(data.employee_id, data.employee_wallet):
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Destination wallet does not belong to the selected employee.",
)
source = await get_wallet(data.source_wallet)
if not source:
raise HTTPException(HTTPStatus.NOT_FOUND, "Source wallet not found.")
if data.source_wallet == data.employee_wallet:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "A contract cannot pay a wallet from itself."
)
if data.currency != "sat" and data.currency not in allowed_currencies():
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'."
)
# Catch this now rather than as a failed payout weeks later, when the
# first back-dated period tries to price itself and finds no rate.
if data.pricing_mode == PricingMode.manual and not data.manual_rate:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Manual pricing needs a rate (units of the currency per BTC).",
)
return employee.display_name
# ---------------------------------------------------------------------------
# Contracts
# ---------------------------------------------------------------------------
@payroll_api_router.get("/api/v1/contracts")
async def api_list_contracts() -> list[Contract]:
return await crud.get_contracts()
@payroll_api_router.get("/api/v1/contracts/{contract_id}")
async def api_get_contract(contract_id: str) -> Contract:
contract = await crud.get_contract(contract_id)
if not contract:
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
return contract
@payroll_api_router.post("/api/v1/contracts", status_code=HTTPStatus.CREATED)
async def api_create_contract(data: CreateContract) -> Contract:
display_name = await _validate_terms(data)
return await crud.create_contract(data, employee_username=display_name)
@payroll_api_router.put("/api/v1/contracts/{contract_id}")
async def api_update_contract(contract_id: str, data: UpdateContract) -> Contract:
"""Patch the terms of a contract.
Only amount/currency/frequency/total_periods/label/memo are patchable —
UpdateContract does not carry the recipient, the source wallet or the
start date. Those three are the contract's identity: changing them would
retroactively alter what the payouts already made were *for*, and
changing the start date would silently re-anchor every remaining payday.
Cancel and re-create instead.
"""
contract = await crud.get_contract(contract_id)
if not contract:
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
patch = data.dict(exclude_unset=True, exclude_none=True)
if "currency" in patch and patch["currency"] != "sat":
if patch["currency"] not in allowed_currencies():
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{patch['currency']}'."
)
# Shrinking a contract below what it has already paid would leave it with
# negative periods remaining; treat the request as an error rather than
# silently completing it.
if "total_periods" in patch and patch["total_periods"] is not None:
if patch["total_periods"] < contract.periods_done:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Contract has already consumed {contract.periods_done} periods.",
)
for field, value in patch.items():
setattr(contract, field, value)
return await crud.update_contract(contract)
@payroll_api_router.delete("/api/v1/contracts/{contract_id}", status_code=HTTPStatus.OK)
async def api_delete_contract(contract_id: str) -> None:
"""Hard-delete a contract row.
This is the "created it by mistake" escape hatch, not the way to stop a
running payroll — deleting throws away the schedule position along with
the row. Cancelling keeps the record.
"""
contract = await crud.get_contract(contract_id)
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 | None = None
) -> Contract:
"""Put a paused contract back to work.
What happens to the paydays missed during the pause depends on who
paused it. An operator pause *was* the decision not to pay them, so they
are written off. A pause payroll applied itself — a period that
exhausted its retries — means the money is still owed and the operator
has just fixed whatever blocked it, so the backlog is kept.
Pass `catch_up` explicitly to override that inference.
"""
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)
# ---------------------------------------------------------------------------
# Payout ledger
# ---------------------------------------------------------------------------
@payroll_api_router.get("/api/v1/payouts")
async def api_list_payouts(
contract_id: str | None = None,
status: PayoutStatus | None = None,
limit: int = 200,
) -> list[Payout]:
"""Every payout attempt, newest first.
Includes failures and skips, not just successes — "why did nobody get
paid on the 1st" is the question this endpoint exists to answer.
"""
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,
pricing_mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> 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.
`pricing_mode`/`manual_rate` price this one payout differently without
editing the contract — for entering a payment that happened weeks ago at
a rate the operator already knows. They apply to this call only.
"""
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, pricing_mode, manual_rate)
except services.LifecycleError as exc:
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc
@payroll_api_router.get("/api/v1/payouts.csv")
async def api_export_payouts_csv(
contract_id: str | None = None,
status: PayoutStatus | None = None,
since: str | None = None,
until: str | None = None,
) -> Response:
"""The ledger as CSV, for accounting.
`since`/`until` bound the payday rather than the row's timestamp, so an
accounting period contains the paydays that belong to it even when one
of them took three days of retries to settle.
"""
payouts = await crud.get_payouts(
contract_id=contract_id,
status=status,
since=since,
until=until,
limit=100_000,
)
filename = f"payroll-{since or 'all'}-{until or 'all'}.csv"
return Response(
content=payouts_to_csv(payouts),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# Employee-facing surface
# ---------------------------------------------------------------------------
# A separate router with a separate gate. Employees are not admins, so this
# is keyed on a wallet's invoice key: whoever holds the read key for a
# wallet may see what payroll has paid *into* that wallet, and nothing else.
# Keeping it off `payroll_api_router` is what stops it inheriting — or
# accidentally shedding — the super-user gate.
payroll_employee_router = APIRouter()
@payroll_employee_router.get("/api/v1/my/payouts")
async def api_my_payouts(
since: str | None = None,
until: str | None = None,
limit: int = 200,
key: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Payout]:
"""Payslips for the wallet whose key signed the request.
Scoped to the wallet rather than to the account on purpose: the invoice
key names exactly one wallet, so there is no lookup that could widen the
result to a sibling wallet the key does not cover.
"""
return await crud.get_payouts(
employee_wallet=key.wallet.id,
since=since,
until=until,
limit=min(limit, 1000),
)
@payroll_employee_router.get("/api/v1/my/payouts.csv")
async def api_my_payouts_csv(
since: str | None = None,
until: str | None = None,
key: WalletTypeInfo = Depends(require_invoice_key),
) -> Response:
"""The same payslips as CSV, so an employee can file their own record."""
payouts = await crud.get_payouts(
employee_wallet=key.wallet.id, since=since, until=until, limit=100_000
)
return Response(
content=payouts_to_csv(payouts),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="payslips.csv"'},
)
@payroll_employee_router.get("/api/v1/my/contracts")
async def api_my_contracts(
key: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Contract]:
"""The payroll lines paying into this wallet — what is still owed, and
when the next one lands."""
return await crud.get_contracts_for_wallet(key.wallet.id)