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
20
crud.py
20
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} "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue