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:
parent
95bfa86f79
commit
8b054d27ea
9 changed files with 389 additions and 30 deletions
53
crud.py
53
crud.py
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import Contract, ContractStatus, CreateContract
|
||||
from .models import Contract, ContractStatus, CreateContract, Payout, PayoutStatus
|
||||
|
||||
db = Database("ext_payroll")
|
||||
|
||||
|
|
@ -87,3 +87,54 @@ async def delete_contract(contract_id: str) -> None:
|
|||
await db.execute(
|
||||
"DELETE FROM payroll.contracts WHERE id = :id", {"id": contract_id}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payout ledger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_payout(payout: Payout) -> Payout:
|
||||
await db.insert("payroll.payouts", payout)
|
||||
return payout
|
||||
|
||||
|
||||
async def get_payouts(
|
||||
contract_id: str | None = None,
|
||||
status: PayoutStatus | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[Payout]:
|
||||
where = []
|
||||
values: dict = {}
|
||||
if contract_id:
|
||||
where.append("contract_id = :cid")
|
||||
values["cid"] = contract_id
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
values["status"] = status.value
|
||||
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM payroll.payouts {clause} "
|
||||
"ORDER BY created_at DESC LIMIT :limit",
|
||||
{**values, "limit": limit},
|
||||
Payout,
|
||||
)
|
||||
|
||||
|
||||
async def count_period_failures(contract_id: str, period_index: int) -> int:
|
||||
"""How many times this exact period has already failed.
|
||||
|
||||
Drives the bounded-retry cap. Counted from the ledger rather than from a
|
||||
counter on the contract so the number survives a restart and stays
|
||||
auditable — the rows that produced it are right there.
|
||||
"""
|
||||
row = await db.fetchone(
|
||||
"SELECT COUNT(*) AS n FROM payroll.payouts "
|
||||
"WHERE contract_id = :cid AND period_index = :idx AND status = :status",
|
||||
{
|
||||
"cid": contract_id,
|
||||
"idx": period_index,
|
||||
"status": PayoutStatus.failed.value,
|
||||
},
|
||||
)
|
||||
return int(row["n"]) if row else 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue