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

132
tests/test_payout.py Normal file
View file

@ -0,0 +1,132 @@
"""Payout sequencing.
`pay_period` itself talks to LNbits wallets, so it is stubbed here — what
these tests pin down is the surrounding contract: which periods get
attempted, in what order, and what a failure does to the contract's
position. Those are the invariants that decide whether a payday can be
paid twice or silently dropped.
"""
import asyncio
from datetime import date
import pytest
from ..models import ContractStatus
from ..services import PeriodOutcome, run_due_periods
from .conftest import make_contract
@pytest.fixture
def payroll_stub(monkeypatch):
"""Replace the DB and the wallet transfer with in-memory doubles.
Returns a recorder holding the contract row and the period indices that
`pay_period` was asked to settle.
"""
class Stub:
def __init__(self):
self.contract = None
self.attempted: list[int] = []
self.outcome_status = "paid"
stub = Stub()
async def fake_get_contract(_contract_id):
return stub.contract
async def fake_update_contract(contract):
stub.contract = contract
return contract
async def fake_pay_period(contract, index, payday):
stub.attempted.append(index)
return PeriodOutcome(
index=index,
payday=payday,
status=stub.outcome_status,
detail="" if stub.outcome_status == "paid" else "stubbed failure",
amount_msat=int(contract.amount) * 1000,
)
from .. import services
monkeypatch.setattr(services.crud, "get_contract", fake_get_contract)
monkeypatch.setattr(services.crud, "update_contract", fake_update_contract)
monkeypatch.setattr(services, "pay_period", fake_pay_period)
# Locks are created inside whichever loop is running; each test drives its
# own asyncio.run, so a lock left over from a previous test would raise.
services._contract_locks.clear()
return stub
def test_backdated_periods_are_skipped_when_backfill_is_off(payroll_stub):
"""A contract created in May with a January start is anchoring a payday
("we pay on the 15th"), not asking for four months of back-pay.
The boundary is the contract's own creation date: Jan-Apr predate it and
are skipped, the 15 May period does not and is paid normally.
"""
payroll_stub.contract = make_contract(
start_date="2026-01-15", created_at="2026-05-01", backfill=False
)
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 5, 20)))
assert [o.status for o in outcomes] == ["skipped"] * 4 + ["paid"]
assert payroll_stub.attempted == [4] # only the first in-life period paid
# Skipped periods still consume the schedule, so the next payday is June.
assert payroll_stub.contract.periods_done == 5
def test_backfill_pays_the_backlog(payroll_stub):
payroll_stub.contract = make_contract(
start_date="2026-01-15", created_at="2026-05-01", backfill=True
)
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 3, 20)))
assert [o.status for o in outcomes] == ["paid", "paid", "paid"]
assert payroll_stub.attempted == [0, 1, 2]
assert payroll_stub.contract.periods_done == 3
def test_a_failure_halts_the_backlog_and_holds_the_position(payroll_stub):
"""The crux: a failed period must not advance the counter, or that
payday is gone. And later periods must not jump the queue."""
payroll_stub.contract = make_contract(
start_date="2026-01-15", created_at="2026-01-01", backfill=True
)
payroll_stub.outcome_status = "failed"
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20)))
assert [o.status for o in outcomes] == ["failed"]
assert payroll_stub.attempted == [0] # period 1 did not jump ahead
assert payroll_stub.contract.periods_done == 0 # retried next tick
def test_contract_completes_when_its_periods_run_out(payroll_stub):
payroll_stub.contract = make_contract(
start_date="2026-01-15",
created_at="2026-01-01",
total_periods=2,
backfill=True,
)
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 6, 1)))
assert payroll_stub.contract.periods_done == 2
assert payroll_stub.contract.status == ContractStatus.completed
def test_nothing_runs_before_the_first_payday(payroll_stub):
payroll_stub.contract = make_contract(
start_date="2026-06-01", created_at="2026-05-01"
)
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 5, 31)))
assert outcomes == []
assert payroll_stub.attempted == []