- Python 76.6%
- HTML 12.2%
- JavaScript 10.9%
- Makefile 0.3%
Found by tracing what happens when a back-dated backfill contract cannot fetch a historical rate. The failure handling itself was fine — period 0 fails, the backlog halts so nothing settles out of order, five ledger rows record the reason, no money moves, and the contract auto-pauses once the retry budget is spent. The recovery was not. The operator fixes the cause (switches to a stated rate, or to current), clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently written off, contract back to looking healthy, employee never paid. The confirm dialog even asserted the missed paydays "are written off" — true of one kind of pause and a lie about the other. Two features colliding. "Do not backfill a deliberate pause" is right when the operator paused: the pause *was* the decision not to pay. It is wrong when payroll paused, because nobody decided anything — the money is still owed and the operator has just removed whatever blocked it. Contracts now carry `paused_reason`, set only when payroll pauses them and cleared by a deliberate pause. Resume infers from it, and an explicit `catch_up` still overrides either way. The console asks a different question for each, quoting the reason, and flags a payroll-paused contract in the table so the distinction is visible before anyone clicks. Verified end to end: five failing ticks leave periods_done at 0 and pause with "period 0 (2026-08-01) failed 5 times: no historical EUR rate available for 2026-08-01"; resuming after switching to a manual rate keeps the position at 0, and the next tick settles all seven owed periods. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj |
||
|---|---|---|
| docs | ||
| static | ||
| templates/payroll | ||
| tests | ||
| .gitignore | ||
| __init__.py | ||
| accounts.py | ||
| config.json | ||
| crud.py | ||
| export.py | ||
| LICENSE | ||
| Makefile | ||
| migrations.py | ||
| models.py | ||
| pyproject.toml | ||
| rates.py | ||
| README.md | ||
| services.py | ||
| tasks.py | ||
| views.py | ||
| views_api.py | ||
Payroll
Scheduled recurring payouts between LNbits wallets, driven by the instance super user.
Pick a user from the account directory, point the payout at one of their wallets, set an amount + currency, a start date, how often it pays and how many times — Payroll then moves the money on schedule, from a source wallet you control, and keeps a ledger of every attempt.
Status
Early. Built for aiolabs LNbits instances; see docs/ for the data model
and operational notes.
Who can use it
Everything under /payroll/api/v1/ that reads the account directory or
touches a contract is gated on check_super_user — payroll moves money
between accounts the operator does not own, so wallet-level admin keys are
deliberately not enough. (require_admin_key authorises writes to your
own wallet only; it is not instance-admin. See the auth table in the
workspace CLAUDE.md.)
Add payroll to LNBITS_ADMIN_EXTENSIONS so the extension is hidden from
ordinary users in the UI as well.
Do not add payroll to LNBITS_ADMIN_EXTENSIONS — see below.
The employee-facing surface — /api/v1/my/payouts, /my/payouts.csv,
/my/contracts — is gated on a wallet invoice key instead. Whoever
holds the read key for a wallet can see what payroll paid into that wallet,
and nothing else.
Employees must have the extension enabled
LNbits gates every extension route per user, not just the UI
(check_user_extension_access, lnbits/decorators.py:420). An employee who
does not have payroll among their active extensions gets
{"detail": "Extension 'payroll' not enabled."}
from /my/payouts, invoice key or not. So enable payroll for anyone who
should read their own payslips — via LNBITS_USER_DEFAULT_EXTENSIONS, or
per account in the Admin UI.
That is also why payroll must stay out of LNBITS_ADMIN_EXTENSIONS.
That list triggers the harder branch of the same check — "User not
authorized for extension" — which a non-admin can never clear, so it would
make the employee surface permanently unreachable. Restricting the operator
console does not depend on it anyway: every super-user route is gated in
code by check_super_user, and the page route checks user.super_user.
Development
make # black + ruff + mypy
make test # pytest
The dev LNbits compose mounts ~/dev/shared/extensions/ at /shared with
LNBITS_EXTENSIONS_PATH=/shared, so this checkout is the installed
extension. FakeWallet is enough — no regtest needed for anything except
end-to-end Lightning behaviour.
Licence
MIT