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