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

@ -191,3 +191,56 @@ class DirectoryUser(BaseModel):
@property
def display_name(self) -> str:
return self.username or self.email or self.id
# ---------------------------------------------------------------------------
# Payout ledger
# ---------------------------------------------------------------------------
class PayoutStatus(str, Enum):
paid = "paid" # money moved
skipped = "skipped" # period deliberately not paid (back-dated, paused-over)
failed = "failed" # attempted and could not settle; will be retried
class Payout(BaseModel):
"""One attempt at one period — the audit trail.
Deliberately self-describing rather than a thin join key. A ledger row
has to still make sense after its contract is edited or deleted, so the
terms in force at the time (amount, currency, both wallets) are copied
onto it. This is the one place in the extension where duplicating a
contract field is right: the contract holds what is true *now*, a payout
holds what was true *then*.
`amount_msat` is the canonical settled figure, taken from the invoice
LNbits priced. It is null on a skip, and on a failure that never got as
far as raising an invoice.
"""
id: str
contract_id: str
period_index: int
payday: str # YYYY-MM-DD
status: PayoutStatus
# 1-based, counted per (contract, period). Only failures accumulate
# attempts — a paid or skipped period is never retried.
attempt: int = 1
amount_msat: int | None = None
amount: float = 0 # the instruction in force at the time
currency: str = "sat"
employee_id: str = ""
employee_wallet: str = ""
source_wallet: str = ""
payment_hash: str | None = None
detail: str = ""
created_at: datetime = Field(default_factory=_now)
@property
def amount_sat(self) -> int | None:
return None if self.amount_msat is None else self.amount_msat // 1000