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:
parent
f253b51aa9
commit
dc29173ada
3 changed files with 257 additions and 0 deletions
23
__init__.py
Normal file
23
__init__.py
Normal 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
69
accounts.py
Normal 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
165
views_api.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue