feat: recurring payout scheduler

Turns a contract into money moving. One permanent task ticks every five
minutes and settles whatever each active contract owes; the actual transfer
is a plain internal LNbits invoice on the employee's wallet, paid from the
source wallet.

services.py is split into a pure half and an effectful half on purpose.
Paydays are the part of payroll that is easy to get subtly wrong and
expensive to get wrong in production, so the schedule math has no DB, no
wallets and no clock of its own, and is covered by tests.

Decisions worth reviewing:

- The n-th payday is a function of start_date and n alone. Advancing a
  stored date would drift on every late tick and would pin a month-end
  contract to the 28th forever; anchoring means 31 Jan pays 28 Feb and then
  31 Mar. Tested both ways round.
- A failed period does not advance the contract's position, and a backlog
  halts at the first failure so paydays cannot settle out of order.
- The sat amount is derived exactly once, by create_invoice, and the value
  it returns is what gets recorded — never recomputed from amount x rate.
- Back-dated start dates skip rather than back-pay by default; a mistyped
  start date is far more likely than a genuine back-pay request. Explicit
  `backfill` opts in, and a single tick is capped at 12 periods either way.
- Per-contract asyncio lock, with the row re-read under it. Not needed by
  the scheduler alone, but off-cycle payout paths land mid-tick and
  double-paying is the worst thing this extension could do.

Known gap, addressed by the payout-ledger commit that follows: a failure is
retried indefinitely, once per tick, with nothing but a log line to show
for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
Padreug 2026-08-31 13:45:40 +02:00
commit 99f2131474
8 changed files with 829 additions and 2 deletions

81
docs/operations.md Normal file
View file

@ -0,0 +1,81 @@
# Running payroll
## The scheduler
One permanent task (`ext_payroll_scheduler`), started with the extension.
It waits ~20s after boot, then every 5 minutes asks each **active**
contract whether it owes a payday.
Minutes, not seconds, on purpose: a payday is a calendar event, so paying
a few minutes into the day costs nothing, and a tight loop would only
multiply log noise while a contract cannot be funded. The post-boot pass
means an instance that was down over a payday catches up on restart rather
than waiting a full interval.
Days are **UTC**. An instance whose operators think in a far-eastern or
far-western timezone will see paydays land on what is locally the previous
or next day.
## How one period is paid
```
create_invoice(employee_wallet, amount, currency, internal=True)
│ ← this is also where a fiat contract is priced in sats
▼
invoice.amount (msat) ← canonical for this period, recorded as-is
│
▼
pay_invoice(source_wallet, invoice.bolt11)
```
A plain internal LNbits wallet-to-wallet transfer. The invoice carries
`extra = {tag: "payroll", contract_id, period, payday}`, so a payroll
transfer is identifiable from the payments list on either wallet.
The sat amount is derived exactly once, by LNbits' own invoice pricing, and
everything downstream reuses that number. Re-deriving it from
`amount × rate` at any later point would drift — FX moves between quote and
settlement, and rounding accumulates over a year of paydays.
If a payout fails after the invoice exists, an unpaid internal invoice is
left on the employee's wallet. It was never settled and expires on its own;
that is the deliberate price of not pricing the period a second time just
to run a balance check.
## What a failure does
A failed period does **not** advance `periods_done`, so the next tick
retries the same payday. Later periods do not jump the queue: the backlog
halts at the first failure so paydays settle in order.
The common failure is an underfunded source wallet, and the log line says
so explicitly with both figures:
```
payroll: contract a1b2c3d4 period 3 failed: insufficient balance in source
wallet: 12000 sat available, 80000 sat required
```
## Back-dated start dates
Creating a contract with a start date in the past is normally an
*anchoring* choice — "we pay on the 1st" — not a request for back-pay.
Periods whose payday fell before the contract row existed are therefore
**skipped**, and skipping still consumes the period, so the schedule stays
aligned to the anchor.
Tick `backfill` on the contract to pay them instead.
Either way a single tick will not fire more than `MAX_CATCH_UP_PERIODS`
(12) periods for one contract, so a mistyped start date cannot turn into a
hundred transfers.
## Double-payment guard
Each contract has an in-process `asyncio.Lock`, and `run_due_periods`
re-reads the contract row under it. The scheduler is a single task, so the
lock exists for the off-cycle paths that can land mid-tick.
This is per-process. Two LNbits processes sharing one database would not
be serialised by it — payroll assumes the single-writer deployment LNbits
itself assumes.