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
74
tests/test_export.py
Normal file
74
tests/test_export.py
Normal file
|
|
@ -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) == ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue