feat: payout ledger with bounded retry

Closes the gap the scheduler commit left open: a failed payday was retried
indefinitely with nothing but a log line to show for it.

Every attempt — paid, skipped and failed — is now written to
payroll.payouts and exposed at GET /api/v1/payouts. Failures are in the
ledger, not only in the log, because "why did nobody get paid on the 1st"
is the question the ledger exists to answer.

Ledger rows are self-describing: each copies the terms in force at the time
(amount, currency, both wallets) instead of pointing at the contract, and
there is no foreign key to contracts. A payout has to still read correctly
after its contract is edited, and deleting a contract must not take its
history with it. This is the one place duplicating a contract field is
right — the contract holds what is true now, a payout holds what was true
then.

Retries are bounded at 5 attempts per period, after which the contract is
paused rather than the period abandoned. A payday that cannot be funded is
a fact somebody has to act on; dropping it silently is the one outcome
payroll must never produce. Pausing does not advance the position, so
resuming after topping up retries the same payday. The attempt count is
derived from the ledger rather than a column on the contract, so it
survives a restart and stays auditable.

Also restores the explanatory comments on the broad `except Exception`
handlers, which ruff's RUF100 stripped along with their now-unused noqa
directives.

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:49:46 +02:00
commit 8b054d27ea
9 changed files with 389 additions and 30 deletions

View file

@ -12,8 +12,8 @@ from datetime import date
import pytest
from ..models import ContractStatus
from ..services import PeriodOutcome, run_due_periods
from ..models import ContractStatus, PayoutStatus
from ..services import MAX_PERIOD_ATTEMPTS, PeriodOutcome, run_due_periods
from .conftest import make_contract
@ -29,7 +29,11 @@ def payroll_stub(monkeypatch):
def __init__(self):
self.contract = None
self.attempted: list[int] = []
self.outcome_status = "paid"
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()
@ -40,13 +44,21 @@ def payroll_stub(monkeypatch):
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):
stub.attempted.append(index)
paid = stub.outcome_status == PayoutStatus.paid
return PeriodOutcome(
index=index,
payday=payday,
status=stub.outcome_status,
detail="" if stub.outcome_status == "paid" else "stubbed failure",
detail="" if paid else "stubbed failure",
amount_msat=int(contract.amount) * 1000,
)
@ -54,6 +66,10 @@ def payroll_stub(monkeypatch):
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.
@ -74,7 +90,9 @@ def test_backdated_periods_are_skipped_when_backfill_is_off(payroll_stub):
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 5, 20)))
assert [o.status for o in outcomes] == ["skipped"] * 4 + ["paid"]
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
@ -87,7 +105,7 @@ def test_backfill_pays_the_backlog(payroll_stub):
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 3, 20)))
assert [o.status for o in outcomes] == ["paid", "paid", "paid"]
assert [p.status for p in outcomes] == [PayoutStatus.paid] * 3
assert payroll_stub.attempted == [0, 1, 2]
assert payroll_stub.contract.periods_done == 3
@ -98,11 +116,11 @@ def test_a_failure_halts_the_backlog_and_holds_the_position(payroll_stub):
payroll_stub.contract = make_contract(
start_date="2026-01-15", created_at="2026-01-01", backfill=True
)
payroll_stub.outcome_status = "failed"
payroll_stub.outcome_status = PayoutStatus.failed
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20)))
assert [o.status for o in outcomes] == ["failed"]
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
@ -130,3 +148,65 @@ def test_nothing_runs_before_the_first_payday(payroll_stub):
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