Until now every payout converted at whatever the rate was when it settled, so a period paid late was silently mispriced. Contracts now carry a pricing mode, and every payout records the rate it used plus where that rate came from — a figure in the ledger can be explained months later instead of merely trusted. Three modes, per contract and overridable per payout: - `payday` (default) converts at what BTC was worth on the payday itself, via the historical lookup. - `current` converts at today's rate — correct when the obligation reads "we owe EUR 800 whenever it settles". - `manual` converts at a rate the operator states (100000 EUR/BTC), for a figure that was agreed rather than looked up. For a payday that is today or ahead, all three collapse to the same thing and none of them touches the network: LNbits' own live pricing is the freshest source available, so `resolve_price` returns the fiat amount unconverted and lets create_invoice do its job. History is consulted only where it can actually change the answer. Where payroll does convert, it must hand create_invoice a sat amount — create_invoice always prices fiat itself and cannot be told a rate. That moves the single conversion point into payroll, which is why the rate and its source are recorded on the payout. In `current` mode the rate is read back off the invoice LNbits priced (extra["btc_rate"]) rather than recomputed, so the row records the number actually applied. An unavailable rate raises PricingError and fails the period. Deliberately no fallback to today's rate: a rate that moved 30% since the payday would pay 30% off and hide it, which is the class of error nobody finds until an audit. The existing retry-then-pause machinery already handles a failed period, and the ledger row names the date and currency that could not be priced. Manual mode missing its rate is caught at contract-creation time instead, rather than surfacing as a failed payout weeks later. m003 defaults preserve behaviour for anything in flight — every live payday is today or ahead, where the modes agree. Verified by applying m003 to a copy of the running instance's database: the existing contract and its paid payout both survive and read back correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
259 lines
9.1 KiB
Python
259 lines
9.1 KiB
Python
"""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, PayoutStatus
|
|
from ..services import (
|
|
MAX_PERIOD_ATTEMPTS,
|
|
LifecycleError,
|
|
PeriodOutcome,
|
|
pay_now,
|
|
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 = PayoutStatus.paid
|
|
self.ledger: list = []
|
|
# Failures already on record for (contract, period), as the real
|
|
# ledger would report them.
|
|
self.prior_failures = 0
|
|
|
|
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_create_payout(payout):
|
|
stub.ledger.append(payout)
|
|
return payout
|
|
|
|
async def fake_count_period_failures(_contract_id, _period_index):
|
|
return stub.prior_failures
|
|
|
|
async def fake_pay_period(contract, index, payday, mode=None, manual_rate=None):
|
|
stub.attempted.append(index)
|
|
paid = stub.outcome_status == PayoutStatus.paid
|
|
return PeriodOutcome(
|
|
index=index,
|
|
payday=payday,
|
|
status=stub.outcome_status,
|
|
detail="" if 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.crud, "create_payout", fake_create_payout)
|
|
monkeypatch.setattr(
|
|
services.crud, "count_period_failures", fake_count_period_failures
|
|
)
|
|
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 [p.status for p in outcomes] == [PayoutStatus.skipped] * 4 + [
|
|
PayoutStatus.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 [p.status for p in outcomes] == [PayoutStatus.paid] * 3
|
|
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 = PayoutStatus.failed
|
|
|
|
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20)))
|
|
|
|
assert [p.status for p in outcomes] == [PayoutStatus.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 == []
|
|
|
|
|
|
# --- ledger ----------------------------------------------------------------
|
|
|
|
|
|
def test_every_attempt_is_recorded_including_skips(payroll_stub):
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-01-15", created_at="2026-03-01", backfill=False
|
|
)
|
|
|
|
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 3, 20)))
|
|
|
|
assert [(p.period_index, p.status) for p in payroll_stub.ledger] == [
|
|
(0, PayoutStatus.skipped),
|
|
(1, PayoutStatus.skipped),
|
|
(2, PayoutStatus.paid),
|
|
]
|
|
|
|
|
|
def test_a_ledger_row_carries_the_terms_in_force(payroll_stub):
|
|
"""Rows have to still read correctly after the contract is edited or
|
|
deleted, so they copy the terms rather than pointing at them."""
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-01-15", created_at="2026-01-01", amount=800, currency="EUR"
|
|
)
|
|
|
|
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 15)))
|
|
|
|
row = payroll_stub.ledger[0]
|
|
assert (row.amount, row.currency) == (800, "EUR")
|
|
assert row.employee_wallet == "wallet-employee"
|
|
assert row.source_wallet == "wallet-treasury"
|
|
assert row.amount_sat == 800 # from the stubbed invoice, not amount x rate
|
|
|
|
|
|
def test_repeated_failure_pauses_the_contract(payroll_stub):
|
|
"""A payday that cannot be funded is a fact somebody has to act on.
|
|
Retrying it silently forever is how a missed salary goes unnoticed."""
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-01-15", created_at="2026-01-01"
|
|
)
|
|
payroll_stub.outcome_status = PayoutStatus.failed
|
|
payroll_stub.prior_failures = MAX_PERIOD_ATTEMPTS - 1
|
|
|
|
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 20)))
|
|
|
|
assert payroll_stub.contract.status == ContractStatus.paused
|
|
# The position did not move, so resuming retries the same payday.
|
|
assert payroll_stub.contract.periods_done == 0
|
|
|
|
|
|
def test_a_failure_under_the_cap_leaves_the_contract_active(payroll_stub):
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-01-15", created_at="2026-01-01"
|
|
)
|
|
payroll_stub.outcome_status = PayoutStatus.failed
|
|
payroll_stub.prior_failures = MAX_PERIOD_ATTEMPTS - 2
|
|
|
|
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 20)))
|
|
|
|
assert payroll_stub.contract.status == ContractStatus.active
|
|
assert payroll_stub.ledger[-1].attempt == MAX_PERIOD_ATTEMPTS - 1
|
|
|
|
|
|
# --- off-cycle payout ------------------------------------------------------
|
|
|
|
|
|
def test_pay_now_settles_the_next_period_before_its_payday(payroll_stub):
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-12-01", created_at="2026-01-01"
|
|
)
|
|
|
|
payout = asyncio.run(pay_now(payroll_stub.contract))
|
|
|
|
assert payout.status == PayoutStatus.paid
|
|
assert payout.period_index == 0
|
|
assert payroll_stub.contract.periods_done == 1
|
|
|
|
|
|
def test_pay_now_ignores_the_back_dated_skip(payroll_stub):
|
|
"""`_settle` skips back-dated periods to stop a new contract firing
|
|
surprise back-pay. An operator explicitly asking to pay is not a
|
|
surprise, so pay_now goes straight to the transfer."""
|
|
payroll_stub.contract = make_contract(
|
|
start_date="2026-01-15", created_at="2026-05-01", backfill=False
|
|
)
|
|
|
|
payout = asyncio.run(pay_now(payroll_stub.contract))
|
|
|
|
assert payout.status == PayoutStatus.paid
|
|
assert payroll_stub.attempted == [0]
|
|
|
|
|
|
def test_pay_now_refuses_a_paused_contract(payroll_stub):
|
|
payroll_stub.contract = make_contract(status=ContractStatus.paused)
|
|
with pytest.raises(LifecycleError):
|
|
asyncio.run(pay_now(payroll_stub.contract))
|
|
|
|
|
|
def test_pay_now_refuses_an_exhausted_contract(payroll_stub):
|
|
payroll_stub.contract = make_contract(total_periods=2, periods_done=2)
|
|
with pytest.raises(LifecycleError):
|
|
asyncio.run(pay_now(payroll_stub.contract))
|