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

@ -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)