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
430 lines
16 KiB
Python
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)
|