payroll/export.py
Padreug 4b6d647d23 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
2026-08-31 13:53:12 +02:00

70 lines
1.8 KiB
Python

"""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()