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
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue