payroll/tasks.py
Padreug 8b054d27ea 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
2026-08-31 13:49:46 +02:00

36 lines
1.2 KiB
Python

"""The payroll scheduler.
One permanent task that wakes up, asks every active contract whether it
owes a payday, and settles the ones that do. Deliberately dumb: all the
decisions live in services.py, and this file only owns the clock.
The tick interval is minutes rather than seconds because a payday is a
calendar event — the cost of paying an hour into the day is nil, and a
tight loop would only multiply log noise when a contract cannot be funded.
A pass also runs shortly after startup so an instance that was down over a
payday catches up without waiting a full interval.
"""
import asyncio
from loguru import logger
from . import services
# How often to look for due paydays.
TICK_SECONDS = 300
# Let the funding source, DB and extension registry settle before the first
# pass — the first tick can move money, so it should not race startup.
STARTUP_DELAY_SECONDS = 20
async def scheduler_loop():
await asyncio.sleep(STARTUP_DELAY_SECONDS)
logger.info("payroll: scheduler started")
while True:
try:
await services.tick()
except Exception as exc: # the loop must outlive any single tick
logger.error(f"payroll: scheduler tick failed: {exc}")
await asyncio.sleep(TICK_SECONDS)