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

23
__init__.py Normal file
View file

@ -0,0 +1,23 @@
from fastapi import APIRouter
from .crud import db
from .views import payroll_generic_router
from .views_api import payroll_api_router
payroll_static_files = [
{
"path": "/payroll/static",
"name": "payroll_static",
}
]
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
payroll_ext.include_router(payroll_generic_router)
payroll_ext.include_router(payroll_api_router)
__all__ = [
"db",
"payroll_ext",
"payroll_static_files",
]

69
accounts.py Normal file
View file

@ -0,0 +1,69 @@
"""Account directory — the read model behind the operator's user picker.
Payroll needs to answer one question core does not expose in a
payroll-shaped way: *which accounts exist, and which wallets could a
payout land in?* This module builds that list out of core's own crud, so
there is no second copy of the account table anywhere in this extension.
It reads accounts the operator does not own, which is exactly why every
caller of it is gated on `check_super_user` at the API boundary.
"""
from lnbits.core.crud import get_accounts, get_wallets
from lnbits.db import Filters
from .models import DirectoryUser, DirectoryWallet
# Core paginates accounts and defaults to 10 per page. A payroll picker
# wants the whole roster in one go; this cap keeps a pathological instance
# from building a five-figure dropdown, and the search box narrows anything
# larger server-side.
MAX_DIRECTORY_USERS = 500
async def list_directory_users(
search: str | None = None, limit: int = MAX_DIRECTORY_USERS
) -> list[DirectoryUser]:
"""Accounts with their (non-deleted) wallets, for the payout picker.
Accounts with no wallet are still returned: they are legitimate payroll
targets, they just need a wallet created before a contract can point at
one. Hiding them would make the operator wonder why a user they can see
in the admin UI is missing here.
"""
page = await get_accounts(
filters=Filters(search=search or None, limit=min(limit, MAX_DIRECTORY_USERS))
)
users: list[DirectoryUser] = []
for account in page.data:
wallets = await get_wallets(account.id)
users.append(
DirectoryUser(
id=account.id,
username=account.username,
email=account.email,
wallets=[
DirectoryWallet(
id=w.id, name=w.name, balance_msat=w.balance_msat
)
for w in wallets
],
)
)
# Readable ordering for a dropdown; core sorts by whatever the page
# query returned, which is account id.
users.sort(key=lambda u: (u.display_name or "").lower())
return users
async def owns_wallet(user_id: str, wallet_id: str) -> bool:
"""Whether `wallet_id` belongs to `user_id`.
The guard that stops a payroll contract from being pointed at a wallet
belonging to somebody other than the selected employee — the id pair
arrives from a form, and nothing else in the write path re-checks it.
"""
wallets = await get_wallets(user_id)
return any(w.id == wallet_id for w in wallets)

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)