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
|
Add `payroll` to `LNBITS_ADMIN_EXTENSIONS` so the extension is hidden from
|
||||||
ordinary users in the UI as well.
|
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
|
## Development
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from fastapi import APIRouter
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from .crud import db
|
from .crud import db
|
||||||
from .views_api import payroll_api_router
|
from .views_api import payroll_api_router, payroll_employee_router
|
||||||
|
|
||||||
payroll_static_files = [
|
payroll_static_files = [
|
||||||
{
|
{
|
||||||
|
|
@ -15,6 +15,7 @@ payroll_static_files = [
|
||||||
|
|
||||||
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
|
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
|
||||||
payroll_ext.include_router(payroll_api_router)
|
payroll_ext.include_router(payroll_api_router)
|
||||||
|
payroll_ext.include_router(payroll_employee_router)
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
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(
|
async def get_payouts(
|
||||||
contract_id: str | None = None,
|
contract_id: str | None = None,
|
||||||
|
employee_wallet: str | None = None,
|
||||||
status: PayoutStatus | None = None,
|
status: PayoutStatus | None = None,
|
||||||
|
since: str | None = None,
|
||||||
|
until: str | None = None,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
) -> list[Payout]:
|
) -> 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 = []
|
where = []
|
||||||
values: dict = {}
|
values: dict = {}
|
||||||
if contract_id:
|
if contract_id:
|
||||||
where.append("contract_id = :cid")
|
where.append("contract_id = :cid")
|
||||||
values["cid"] = contract_id
|
values["cid"] = contract_id
|
||||||
|
if employee_wallet:
|
||||||
|
where.append("employee_wallet = :wid")
|
||||||
|
values["wid"] = employee_wallet
|
||||||
if status:
|
if status:
|
||||||
where.append("status = :status")
|
where.append("status = :status")
|
||||||
values["status"] = status.value
|
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 ""
|
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||||
return await db.fetchall(
|
return await db.fetchall(
|
||||||
f"SELECT * FROM payroll.payouts {clause} "
|
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
|
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
|
failed — a manual payout that did not land is exactly when you want to know
|
||||||
why.
|
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 datetime import date, datetime
|
||||||
from http import HTTPStatus
|
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.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 lnbits.utils.exchange_rates import allowed_currencies
|
||||||
|
|
||||||
from . import crud, services
|
from . import crud, services
|
||||||
from .accounts import list_directory_users, owns_wallet
|
from .accounts import list_directory_users, owns_wallet
|
||||||
|
from .export import payouts_to_csv
|
||||||
from .models import (
|
from .models import (
|
||||||
Contract,
|
Contract,
|
||||||
CreateContract,
|
CreateContract,
|
||||||
|
|
@ -318,3 +320,90 @@ async def api_pay_now(contract_id: str) -> Payout:
|
||||||
return await services.pay_now(contract)
|
return await services.pay_now(contract)
|
||||||
except services.LifecycleError as exc:
|
except services.LifecycleError as exc:
|
||||||
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from 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