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:
parent
ee10b2e04d
commit
4b6d647d23
7 changed files with 294 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
20
crud.py
|
|
@ -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} "
|
||||
|
|
|
|||
|
|
@ -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
70
export.py
Normal 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
74
tests/test_export.py
Normal 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) == ""
|
||||
93
views_api.py
93
views_api.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue