diff --git a/README.md b/README.md index f1adf29..6897391 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/__init__.py b/__init__.py index a60bb29..6a49a2c 100644 --- a/__init__.py +++ b/__init__.py @@ -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] = [] diff --git a/crud.py b/crud.py index 2c76717..8029f26 100644 --- a/crud.py +++ b/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} " diff --git a/docs/operations.md b/docs/operations.md index b0ad578..888c67b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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. diff --git a/export.py b/export.py new file mode 100644 index 0000000..987a8d5 --- /dev/null +++ b/export.py @@ -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() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..e6fe06b --- /dev/null +++ b/tests/test_export.py @@ -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) == "" diff --git a/views_api.py b/views_api.py index 5d5db04..7218974 100644 --- a/views_api.py +++ b/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)