payroll/tests/test_payout.py
Padreug 18a17cd693 feat: price a back-dated period at the day it was due
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
2026-08-31 21:42:00 +02:00

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))