From dc29173ada19adda4829ae02663933fd14611d0c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:40:18 +0200 Subject: [PATCH] feat: super-user REST API for payroll contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- __init__.py | 23 +++++++ accounts.py | 69 +++++++++++++++++++++ views_api.py | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 __init__.py create mode 100644 accounts.py create mode 100644 views_api.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e7c8aaf --- /dev/null +++ b/__init__.py @@ -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", +] diff --git a/accounts.py b/accounts.py new file mode 100644 index 0000000..0d59673 --- /dev/null +++ b/accounts.py @@ -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) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..bd8afef --- /dev/null +++ b/views_api.py @@ -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)