`fetchone` without a model leaves its TModel unbound, so the COUNT(*) row
needs an explicit type. Completes the black + ruff + mypy pipeline the
Makefile declares.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
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
Closes the gap the scheduler commit left open: a failed payday was retried
indefinitely with nothing but a log line to show for it.
Every attempt — paid, skipped and failed — is now written to
payroll.payouts and exposed at GET /api/v1/payouts. Failures are in the
ledger, not only in the log, because "why did nobody get paid on the 1st"
is the question the ledger exists to answer.
Ledger rows are self-describing: each copies the terms in force at the time
(amount, currency, both wallets) instead of pointing at the contract, and
there is no foreign key to contracts. A payout has to still read correctly
after its contract is edited, and deleting a contract must not take its
history with it. This is the one place duplicating a contract field is
right — the contract holds what is true now, a payout holds what was true
then.
Retries are bounded at 5 attempts per period, after which the contract is
paused rather than the period abandoned. A payday that cannot be funded is
a fact somebody has to act on; dropping it silently is the one outcome
payroll must never produce. Pausing does not advance the position, so
resuming after topping up retries the same payday. The attempt count is
derived from the ledger rather than a column on the contract, so it
survives a restart and stays auditable.
Also restores the explanatory comments on the broad `except Exception`
handlers, which ruff's RUF100 stripped along with their now-unused noqa
directives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
The one entity payroll needs: a standing instruction to pay an employee a
fixed amount, at a fixed cadence, from a given start date, a given number
of times.
Two design decisions worth reviewing here, both documented in
docs/data-model.md:
- Schedule position is `periods_done` (a counter), not a stored
`next_run_at`. Every payday is recomputed as occurrence(start_date,
frequency, n), so a late tick cannot make the schedule drift, and a
contract anchored on the 31st pays 28 Feb then 31 Mar rather than being
permanently pinned to the 28th.
- `periods_done` counts periods *consumed* (paid or deliberately skipped),
not periods successfully paid. A failed payout leaves it untouched so the
next tick retries that payday instead of dropping it.
There is no employee table — an employee is an LNbits account. Only
`employee_username` is copied, and only as a display label so history stays
readable after a rename; authorisation always goes through `employee_id`.
Ordinary migrations, not the migrations_fork.py split: this is an
aiolabs-original extension, so there is no upstream migrations.py to stay
byte-identical with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj