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

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)