feat: super-user REST API for payroll contracts

Contract CRUD plus the account directory the operator picks an employee
from. The extension loads and is fully drivable over HTTP after this
commit; the console UI lands separately.

Auth: the gate is `check_super_user`, applied at the *router* level so a
new endpoint cannot be added ungated by forgetting a decorator. Payroll
reads accounts the caller does not own and moves money between their
wallets, so a wallet-scoped `require_admin_key` — which any user holds for
their own wallets — would be the wrong gate here despite the similar name.

Validation lives in one place (`_validate_terms`) because the employee and
the destination wallet arrive from a form as two independent ids and
nothing further down the write path re-checks that they belong together.
That check is what stops a payout being aimed at a third party's wallet.

Edits are deliberately partial: recipient, source wallet and start date are
not patchable. They are the contract's identity — changing them would
retroactively alter what already-made payouts were for, and re-anchoring
the start date would silently move every remaining payday.

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:40:18 +02:00
commit dc29173ada
3 changed files with 257 additions and 0 deletions

165
views_api.py Normal file
View file

@ -0,0 +1,165 @@
"""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 http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.crud import get_wallet
from lnbits.decorators import check_super_user
from lnbits.utils.exchange_rates import allowed_currencies
from . import crud
from .accounts import list_directory_users, owns_wallet
from .models import Contract, CreateContract, DirectoryUser, 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}'."
)
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)