feat: CSV export and employee payslip view

Two audiences the super-user API did not serve.

Accounting gets `GET /api/v1/payouts.csv`, filterable by contract, status
and date range. The range bounds the *payday* rather than the row
timestamp, so a period contains the paydays that belong to it even when one
of them took three days of retries to settle — otherwise a late retry lands
in the wrong month's export.

Every exported field is neutralised against spreadsheet formula injection.
`detail` carries exception text and the memo carries operator input, and a
cell beginning `=`, `+`, `-` or `@` executes when the file is opened. Worth
the eight lines: this file is written specifically to be opened in somebody
else's spreadsheet.

Employees get `/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts`
on a **separate router** gated by a wallet invoice key rather than by
super-user rights. Separate router so it cannot inherit — or accidentally
shed — the wrong gate. Scoped to the wallet rather than the account because
an invoice key names exactly one wallet, leaving no lookup that could widen
the result to a sibling wallet the key does not cover.

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:53:12 +02:00
commit 4b6d647d23
7 changed files with 294 additions and 3 deletions

View file

@ -25,6 +25,13 @@ workspace `CLAUDE.md`.)
Add `payroll` to `LNBITS_ADMIN_EXTENSIONS` so the extension is hidden from
ordinary users in the UI as well.
The exception is the employee-facing surface — `/api/v1/my/payouts`,
`/my/payouts.csv`, `/my/contracts` — which is gated on a wallet **invoice
key** instead. Whoever holds the read key for a wallet can see what payroll
paid into that wallet, and nothing else. It stays reachable regardless of
`LNBITS_ADMIN_EXTENSIONS`, which governs UI visibility rather than route
mounting.
## Development
```

View file

@ -4,7 +4,7 @@ from fastapi import APIRouter
from loguru import logger
from .crud import db
from .views_api import payroll_api_router
from .views_api import payroll_api_router, payroll_employee_router
payroll_static_files = [
{
@ -15,6 +15,7 @@ payroll_static_files = [
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
payroll_ext.include_router(payroll_api_router)
payroll_ext.include_router(payroll_employee_router)
scheduled_tasks: list[asyncio.Task] = []

20
crud.py
View file

@ -101,17 +101,37 @@ async def create_payout(payout: Payout) -> Payout:
async def get_payouts(
contract_id: str | None = None,
employee_wallet: str | None = None,
status: PayoutStatus | None = None,
since: str | None = None,
until: str | None = None,
limit: int = 200,
) -> list[Payout]:
"""Ledger rows, newest first.
`since`/`until` bound the **payday**, not the row's creation time: an
accounting period is about which paydays fall in it, and a payday that
was retried for three days would otherwise land in the wrong month.
Both are inclusive `YYYY-MM-DD`, which compares correctly as a string
because that is the only format `payday` is ever written in.
"""
where = []
values: dict = {}
if contract_id:
where.append("contract_id = :cid")
values["cid"] = contract_id
if employee_wallet:
where.append("employee_wallet = :wid")
values["wid"] = employee_wallet
if status:
where.append("status = :status")
values["status"] = status.value
if since:
where.append("payday >= :since")
values["since"] = since
if until:
where.append("payday <= :until")
values["until"] = until
clause = f"WHERE {' AND '.join(where)}" if where else ""
return await db.fetchall(
f"SELECT * FROM payroll.payouts {clause} "

View file

@ -134,3 +134,33 @@ It is recorded in the ledger like any other payout, with the early payment
noted in `detail`, and the ledger row is returned even when the payout
failed — a manual payout that did not land is exactly when you want to know
why.
## Accounting export
`GET /payroll/api/v1/payouts.csv` (super user) exports the ledger, with
optional `contract_id`, `status`, `since` and `until`. `since`/`until` bound
the **payday**, not the row timestamp, so an accounting period contains the
paydays that belong to it even when one took three days of retries to
settle.
Every exported field is neutralised against spreadsheet formula injection —
a cell beginning `=`, `+`, `-` or `@` is prefixed with an apostrophe.
`detail` carries exception text and the memo carries operator input, and
neither is worth trusting to a colleague's Excel.
## What an employee can see
`/payroll/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts` are
gated on a **wallet invoice key**, not on admin rights: whoever holds the
read key for a wallet may see what payroll has paid into that wallet, and
nothing else. They live on a separate router from the super-user API so
they cannot inherit — or accidentally shed — the wrong gate.
Scoped to the wallet rather than to the account on purpose: an invoice key
names exactly one wallet, so there is no lookup that could widen the result
to a sibling wallet the key does not cover.
Note this stays reachable even with `payroll` in `LNBITS_ADMIN_EXTENSIONS`.
That flag governs who sees the extension in the UI; the API routes are
mounted instance-wide, and these three are gated by the wallet key they
require rather than by UI visibility.

70
export.py Normal file
View file

@ -0,0 +1,70 @@
"""CSV export of the payout ledger.
Payroll data leaves this extension for a spreadsheet, and a spreadsheet
treats a cell beginning with `=`, `+`, `-` or `@` as a formula. Every field
that reaches a cell is therefore neutralised on the way out — `detail`
carries exception text and the memo carries operator input, and neither is
worth trusting to a colleague's Excel.
"""
import csv
import io
from .models import Payout
COLUMNS = [
"payday",
"status",
"attempt",
"amount",
"currency",
"amount_sat",
"contract_id",
"period_index",
"employee_id",
"employee_wallet",
"source_wallet",
"payment_hash",
"detail",
"recorded_at",
]
# Leading characters a spreadsheet reads as the start of a formula.
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
def csv_safe(value) -> str:
"""Render one value so a spreadsheet treats it as text, never a formula."""
text = "" if value is None else str(value)
if text.startswith(_FORMULA_PREFIXES):
return "'" + text
return text
def payouts_to_csv(payouts: list[Payout]) -> str:
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(COLUMNS)
for p in payouts:
writer.writerow(
[
csv_safe(v)
for v in (
p.payday,
p.status.value,
p.attempt,
p.amount,
p.currency,
p.amount_sat,
p.contract_id,
p.period_index,
p.employee_id,
p.employee_wallet,
p.source_wallet,
p.payment_hash,
p.detail,
p.created_at.isoformat(),
)
]
)
return buffer.getvalue()

74
tests/test_export.py Normal file
View file

@ -0,0 +1,74 @@
"""CSV export.
The interesting part is not the formatting — it is that payroll data leaves
here for a spreadsheet, and a spreadsheet executes a cell that starts with
`=`, `+`, `-` or `@`.
"""
from datetime import datetime, timezone
from ..export import COLUMNS, csv_safe, payouts_to_csv
from ..models import Payout, PayoutStatus
def make_payout(**overrides) -> Payout:
return Payout(
**{
"id": "p1",
"contract_id": "c1",
"period_index": 0,
"payday": "2026-01-15",
"status": PayoutStatus.paid,
"amount_msat": 800_000,
"amount": 800,
"currency": "sat",
"employee_id": "employee-account",
"employee_wallet": "wallet-employee",
"source_wallet": "wallet-treasury",
"created_at": datetime(2026, 1, 15, 9, 0, tzinfo=timezone.utc),
**overrides,
}
)
def test_header_and_one_row():
out = payouts_to_csv([make_payout()])
lines = out.strip().splitlines()
assert lines[0] == ",".join(COLUMNS)
assert lines[1].startswith("2026-01-15,paid,1,800.0,sat,800,c1,0,")
def test_amount_sat_comes_from_the_settled_msat_figure():
row = payouts_to_csv(
[make_payout(amount=800, currency="EUR", amount_msat=1_234_000)]
)
assert ",1234," in row # not re-derived from the EUR amount
def test_a_failed_row_exports_with_no_sat_amount():
out = payouts_to_csv(
[make_payout(status=PayoutStatus.failed, amount_msat=None, detail="no funds")]
)
assert "failed" in out and "no funds" in out
# --- spreadsheet formula injection -----------------------------------------
def test_formula_prefixes_are_neutralised():
for dangerous in ("=1+1", "+1", "-1", "@SUM(A1)", "\tx", "\rx"):
assert csv_safe(dangerous).startswith("'")
def test_ordinary_values_are_untouched():
for benign in ("2026-01-15", "paid", "wallet-employee", "800.0", ""):
assert csv_safe(benign) == benign
def test_a_hostile_detail_cannot_smuggle_a_formula_into_a_cell():
out = payouts_to_csv([make_payout(detail="=cmd|'/c calc'!A1")])
assert "'=cmd" in out
def test_none_becomes_an_empty_cell():
assert csv_safe(None) == ""

View file

@ -15,13 +15,15 @@ the workspace CLAUDE.md.)
from datetime import date, datetime
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Response
from lnbits.core.crud import get_wallet
from lnbits.decorators import check_super_user
from lnbits.core.models import WalletTypeInfo
from lnbits.decorators import check_super_user, require_invoice_key
from lnbits.utils.exchange_rates import allowed_currencies
from . import crud, services
from .accounts import list_directory_users, owns_wallet
from .export import payouts_to_csv
from .models import (
Contract,
CreateContract,
@ -318,3 +320,90 @@ async def api_pay_now(contract_id: str) -> Payout:
return await services.pay_now(contract)
except services.LifecycleError as exc:
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc
@payroll_api_router.get("/api/v1/payouts.csv")
async def api_export_payouts_csv(
contract_id: str | None = None,
status: PayoutStatus | None = None,
since: str | None = None,
until: str | None = None,
) -> Response:
"""The ledger as CSV, for accounting.
`since`/`until` bound the payday rather than the row's timestamp, so an
accounting period contains the paydays that belong to it even when one
of them took three days of retries to settle.
"""
payouts = await crud.get_payouts(
contract_id=contract_id,
status=status,
since=since,
until=until,
limit=100_000,
)
filename = f"payroll-{since or 'all'}-{until or 'all'}.csv"
return Response(
content=payouts_to_csv(payouts),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ---------------------------------------------------------------------------
# Employee-facing surface
# ---------------------------------------------------------------------------
# A separate router with a separate gate. Employees are not admins, so this
# is keyed on a wallet's invoice key: whoever holds the read key for a
# wallet may see what payroll has paid *into* that wallet, and nothing else.
# Keeping it off `payroll_api_router` is what stops it inheriting — or
# accidentally shedding — the super-user gate.
payroll_employee_router = APIRouter()
@payroll_employee_router.get("/api/v1/my/payouts")
async def api_my_payouts(
since: str | None = None,
until: str | None = None,
limit: int = 200,
key: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Payout]:
"""Payslips for the wallet whose key signed the request.
Scoped to the wallet rather than to the account on purpose: the invoice
key names exactly one wallet, so there is no lookup that could widen the
result to a sibling wallet the key does not cover.
"""
return await crud.get_payouts(
employee_wallet=key.wallet.id,
since=since,
until=until,
limit=min(limit, 1000),
)
@payroll_employee_router.get("/api/v1/my/payouts.csv")
async def api_my_payouts_csv(
since: str | None = None,
until: str | None = None,
key: WalletTypeInfo = Depends(require_invoice_key),
) -> Response:
"""The same payslips as CSV, so an employee can file their own record."""
payouts = await crud.get_payouts(
employee_wallet=key.wallet.id, since=since, until=until, limit=100_000
)
return Response(
content=payouts_to_csv(payouts),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="payslips.csv"'},
)
@payroll_employee_router.get("/api/v1/my/contracts")
async def api_my_contracts(
key: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Contract]:
"""The payroll lines paying into this wallet — what is still owed, and
when the next one lands."""
return await crud.get_contracts_for_wallet(key.wallet.id)