No description
  • Python 76.6%
  • HTML 12.2%
  • JavaScript 10.9%
  • Makefile 0.3%
Find a file
Padreug e77f431d47 fix: resuming an auto-paused contract no longer discards the backlog
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
2026-08-31 23:02:45 +02:00
docs fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
static fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
templates/payroll fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
tests fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
.gitignore chore: ignore the lnbits runtime data folder 2026-08-31 13:45:21 +02:00
__init__.py feat(ui): super-user payroll console 2026-08-31 13:56:16 +02:00
accounts.py style: run black and ruff over the committed sources 2026-08-31 13:45:27 +02:00
config.json chore: scaffold payroll extension 2026-08-31 13:36:32 +02:00
crud.py chore: annotate the aggregate row so mypy passes 2026-08-31 13:56:45 +02:00
export.py feat: CSV export and employee payslip view 2026-08-31 13:53:12 +02:00
LICENSE chore: scaffold payroll extension 2026-08-31 13:36:32 +02:00
Makefile chore: scaffold payroll extension 2026-08-31 13:36:32 +02:00
migrations.py fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
models.py fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
pyproject.toml chore: scaffold payroll extension 2026-08-31 13:36:32 +02:00
rates.py docs: record the measured reach of the rate lookup 2026-08-31 21:44:21 +02:00
README.md docs: correct how the employee payslip surface is gated 2026-08-31 16:16:19 +02:00
services.py fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00
tasks.py feat: payout ledger with bounded retry 2026-08-31 13:49:46 +02:00
views.py feat(ui): super-user payroll console 2026-08-31 13:56:16 +02:00
views_api.py fix: resuming an auto-paused contract no longer discards the backlog 2026-08-31 23:02:45 +02:00

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