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
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
43
tests/conftest.py
Normal file
43
tests/conftest.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Test fixtures for payroll.
|
||||
|
||||
The schedule math is pure, so most tests need nothing but a `Contract`
|
||||
instance — no LNbits DB, no wallets, no event loop. That is the point of
|
||||
keeping `services` split into a pure half and an effectful half.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..models import Contract, ContractStatus, Frequency
|
||||
|
||||
|
||||
def make_contract(
|
||||
*,
|
||||
contract_id: str = "c1",
|
||||
start_date: str = "2026-01-15",
|
||||
frequency: Frequency = Frequency.monthly,
|
||||
total_periods: int | None = 12,
|
||||
periods_done: int = 0,
|
||||
status: ContractStatus = ContractStatus.active,
|
||||
amount: float = 1000,
|
||||
currency: str = "sat",
|
||||
backfill: bool = False,
|
||||
created_at: str = "2026-01-01",
|
||||
) -> Contract:
|
||||
return Contract(
|
||||
id=contract_id,
|
||||
employee_id="employee-account",
|
||||
employee_username="alice",
|
||||
employee_wallet="wallet-employee",
|
||||
source_wallet="wallet-treasury",
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
frequency=frequency,
|
||||
start_date=start_date,
|
||||
total_periods=total_periods,
|
||||
periods_done=periods_done,
|
||||
status=status,
|
||||
backfill=backfill,
|
||||
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
|
||||
tzinfo=timezone.utc
|
||||
),
|
||||
)
|
||||
132
tests/test_payout.py
Normal file
132
tests/test_payout.py
Normal 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 == []
|
||||
149
tests/test_schedule.py
Normal file
149
tests/test_schedule.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Schedule math.
|
||||
|
||||
These are the cases that make month-anchored payroll wrong in practice:
|
||||
month-end clamping, clamping that must not become permanent, leap days, and
|
||||
a schedule that must not drift no matter how late the scheduler runs.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from ..models import ContractStatus, Frequency
|
||||
from ..services import (
|
||||
MAX_CATCH_UP_PERIODS,
|
||||
add_months,
|
||||
due_period_indices,
|
||||
next_payday,
|
||||
occurrence_on,
|
||||
upcoming_paydays,
|
||||
)
|
||||
from .conftest import make_contract
|
||||
|
||||
# --- month arithmetic ------------------------------------------------------
|
||||
|
||||
|
||||
def test_add_months_clamps_into_a_short_month():
|
||||
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
|
||||
|
||||
|
||||
def test_clamping_is_not_permanent():
|
||||
"""The whole reason paydays are computed from the anchor and not from the
|
||||
previous payday: 31 Jan must pay 28 Feb and then *31* Mar, not 28 Mar."""
|
||||
anchor = date(2026, 1, 31)
|
||||
assert add_months(anchor, 1) == date(2026, 2, 28)
|
||||
assert add_months(anchor, 2) == date(2026, 3, 31)
|
||||
assert add_months(anchor, 3) == date(2026, 4, 30)
|
||||
assert add_months(anchor, 4) == date(2026, 5, 31)
|
||||
|
||||
|
||||
def test_add_months_crosses_year_boundaries():
|
||||
assert add_months(date(2026, 11, 30), 3) == date(2027, 2, 28)
|
||||
assert add_months(date(2026, 3, 15), -4) == date(2025, 11, 15)
|
||||
|
||||
|
||||
def test_leap_day_anchor_clamps_in_common_years():
|
||||
leap = date(2028, 2, 29)
|
||||
assert add_months(leap, 12) == date(2029, 2, 28)
|
||||
assert add_months(leap, 48) == date(2032, 2, 29)
|
||||
|
||||
|
||||
# --- occurrences -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frequency", "index", "expected"),
|
||||
[
|
||||
(Frequency.daily, 0, date(2026, 1, 15)),
|
||||
(Frequency.daily, 20, date(2026, 2, 4)),
|
||||
(Frequency.weekly, 3, date(2026, 2, 5)),
|
||||
(Frequency.biweekly, 2, date(2026, 2, 12)),
|
||||
(Frequency.monthly, 2, date(2026, 3, 15)),
|
||||
(Frequency.quarterly, 2, date(2026, 7, 15)),
|
||||
(Frequency.yearly, 2, date(2028, 1, 15)),
|
||||
],
|
||||
)
|
||||
def test_occurrence_on(frequency, index, expected):
|
||||
assert occurrence_on(date(2026, 1, 15), frequency, index) == expected
|
||||
|
||||
|
||||
def test_period_zero_is_the_start_date_for_every_frequency():
|
||||
start = date(2026, 6, 30)
|
||||
for frequency in Frequency:
|
||||
assert occurrence_on(start, frequency, 0) == start
|
||||
|
||||
|
||||
def test_schedule_does_not_drift_over_a_year():
|
||||
"""A monthly contract must land on the same day twelve months later —
|
||||
incrementally advancing a stored date is what would break this."""
|
||||
start = date(2026, 1, 15)
|
||||
assert occurrence_on(start, Frequency.monthly, 12) == date(2027, 1, 15)
|
||||
|
||||
|
||||
# --- due periods -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_nothing_is_due_before_the_start_date():
|
||||
contract = make_contract(start_date="2026-03-01")
|
||||
assert due_period_indices(contract, date(2026, 2, 28)) == []
|
||||
|
||||
|
||||
def test_the_start_date_itself_is_due():
|
||||
contract = make_contract(start_date="2026-03-01")
|
||||
assert due_period_indices(contract, date(2026, 3, 1)) == [0]
|
||||
|
||||
|
||||
def test_a_backlog_returns_every_missed_period_in_order():
|
||||
contract = make_contract(start_date="2026-01-15", frequency=Frequency.monthly)
|
||||
assert due_period_indices(contract, date(2026, 4, 20)) == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_periods_already_done_are_not_due_again():
|
||||
contract = make_contract(start_date="2026-01-15", periods_done=3)
|
||||
assert due_period_indices(contract, date(2026, 4, 20)) == [3]
|
||||
|
||||
|
||||
def test_due_periods_stop_at_the_period_cap():
|
||||
contract = make_contract(start_date="2026-01-15", total_periods=2)
|
||||
assert due_period_indices(contract, date(2026, 12, 31)) == [0, 1]
|
||||
|
||||
|
||||
def test_catch_up_is_capped_for_open_ended_contracts():
|
||||
"""A daily contract with a badly back-dated start must not try to fire
|
||||
hundreds of transfers in one tick."""
|
||||
contract = make_contract(
|
||||
start_date="2020-01-01", frequency=Frequency.daily, total_periods=None
|
||||
)
|
||||
assert len(due_period_indices(contract, date(2026, 1, 1))) == MAX_CATCH_UP_PERIODS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
|
||||
)
|
||||
def test_only_active_contracts_are_due(status):
|
||||
contract = make_contract(start_date="2026-01-15", status=status)
|
||||
assert due_period_indices(contract, date(2026, 6, 1)) == []
|
||||
|
||||
|
||||
# --- previews --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_next_payday_follows_the_position():
|
||||
contract = make_contract(start_date="2026-01-31", periods_done=1)
|
||||
assert next_payday(contract) == date(2026, 2, 28)
|
||||
|
||||
|
||||
def test_next_payday_is_none_once_exhausted():
|
||||
contract = make_contract(total_periods=3, periods_done=3)
|
||||
assert next_payday(contract) is None
|
||||
|
||||
|
||||
def test_upcoming_paydays_are_truncated_by_the_period_cap():
|
||||
contract = make_contract(start_date="2026-01-15", total_periods=3, periods_done=1)
|
||||
assert upcoming_paydays(contract, 10) == [date(2026, 2, 15), date(2026, 3, 15)]
|
||||
|
||||
|
||||
def test_upcoming_paydays_are_unbounded_for_open_ended_contracts():
|
||||
contract = make_contract(start_date="2026-01-15", total_periods=None)
|
||||
assert len(upcoming_paydays(contract, 24)) == 24
|
||||
Loading…
Add table
Add a link
Reference in a new issue