Two things an operator needs that the scheduler alone does not give them.
Preview draws a contract's calendar from loose terms — start date,
frequency, period count — with no employee or wallet required, because the
schedule is what an operator wants to sanity-check first and a mistyped
start date is cheapest to fix before anything is saved. The same shape is
available for a live contract, from its current position. Both return
`ends_on`: "12 monthly payments from 15 Jan" is far easier to verify
against "ends 15 Dec" than against a list of twelve dates.
pay-now settles the next period immediately and consumes it. Deliberately
one endpoint rather than two: "run it now, don't wait for the tick" and
"pay it early" are the same operation and differ only in whether today
happens to be the payday. It bypasses the back-dated skip — that guard
exists to stop a new contract firing surprise back-pay, and an operator
explicitly asking to pay is not a surprise — and it takes the same
per-contract lock as the scheduler, which is what that lock was added for.
The ledger row is returned even on failure, since a manual payout that did
not land is exactly when you want the reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
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