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:
parent
55f3dfac91
commit
99f2131474
8 changed files with 829 additions and 2 deletions
36
tasks.py
Normal file
36
tasks.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""The payroll scheduler.
|
||||
|
||||
One permanent task that wakes up, asks every active contract whether it
|
||||
owes a payday, and settles the ones that do. Deliberately dumb: all the
|
||||
decisions live in services.py, and this file only owns the clock.
|
||||
|
||||
The tick interval is minutes rather than seconds because a payday is a
|
||||
calendar event — the cost of paying an hour into the day is nil, and a
|
||||
tight loop would only multiply log noise when a contract cannot be funded.
|
||||
A pass also runs shortly after startup so an instance that was down over a
|
||||
payday catches up without waiting a full interval.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from . import services
|
||||
|
||||
# How often to look for due paydays.
|
||||
TICK_SECONDS = 300
|
||||
|
||||
# Let the funding source, DB and extension registry settle before the first
|
||||
# pass — the first tick can move money, so it should not race startup.
|
||||
STARTUP_DELAY_SECONDS = 20
|
||||
|
||||
|
||||
async def scheduler_loop():
|
||||
await asyncio.sleep(STARTUP_DELAY_SECONDS)
|
||||
logger.info("payroll: scheduler started")
|
||||
while True:
|
||||
try:
|
||||
await services.tick()
|
||||
except Exception as exc:
|
||||
logger.error(f"payroll: scheduler tick failed: {exc}")
|
||||
await asyncio.sleep(TICK_SECONDS)
|
||||
Loading…
Add table
Add a link
Reference in a new issue