From dc29173ada19adda4829ae02663933fd14611d0c Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:40:18 +0200 Subject: [PATCH 01/10] feat: super-user REST API for payroll contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract CRUD plus the account directory the operator picks an employee from. The extension loads and is fully drivable over HTTP after this commit; the console UI lands separately. Auth: the gate is `check_super_user`, applied at the *router* level so a new endpoint cannot be added ungated by forgetting a decorator. Payroll reads accounts the caller does not own and moves money between their wallets, so a wallet-scoped `require_admin_key` — which any user holds for their own wallets — would be the wrong gate here despite the similar name. Validation lives in one place (`_validate_terms`) because the employee and the destination wallet arrive from a form as two independent ids and nothing further down the write path re-checks that they belong together. That check is what stops a payout being aimed at a third party's wallet. Edits are deliberately partial: recipient, source wallet and start date are not patchable. They are the contract's identity — changing them would retroactively alter what already-made payouts were for, and re-anchoring the start date would silently move every remaining payday. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- __init__.py | 23 +++++++ accounts.py | 69 +++++++++++++++++++++ views_api.py | 165 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 __init__.py create mode 100644 accounts.py create mode 100644 views_api.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e7c8aaf --- /dev/null +++ b/__init__.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter + +from .crud import db +from .views import payroll_generic_router +from .views_api import payroll_api_router + +payroll_static_files = [ + { + "path": "/payroll/static", + "name": "payroll_static", + } +] + +payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"]) +payroll_ext.include_router(payroll_generic_router) +payroll_ext.include_router(payroll_api_router) + + +__all__ = [ + "db", + "payroll_ext", + "payroll_static_files", +] diff --git a/accounts.py b/accounts.py new file mode 100644 index 0000000..0d59673 --- /dev/null +++ b/accounts.py @@ -0,0 +1,69 @@ +"""Account directory — the read model behind the operator's user picker. + +Payroll needs to answer one question core does not expose in a +payroll-shaped way: *which accounts exist, and which wallets could a +payout land in?* This module builds that list out of core's own crud, so +there is no second copy of the account table anywhere in this extension. + +It reads accounts the operator does not own, which is exactly why every +caller of it is gated on `check_super_user` at the API boundary. +""" + +from lnbits.core.crud import get_accounts, get_wallets +from lnbits.db import Filters + +from .models import DirectoryUser, DirectoryWallet + +# Core paginates accounts and defaults to 10 per page. A payroll picker +# wants the whole roster in one go; this cap keeps a pathological instance +# from building a five-figure dropdown, and the search box narrows anything +# larger server-side. +MAX_DIRECTORY_USERS = 500 + + +async def list_directory_users( + search: str | None = None, limit: int = MAX_DIRECTORY_USERS +) -> list[DirectoryUser]: + """Accounts with their (non-deleted) wallets, for the payout picker. + + Accounts with no wallet are still returned: they are legitimate payroll + targets, they just need a wallet created before a contract can point at + one. Hiding them would make the operator wonder why a user they can see + in the admin UI is missing here. + """ + page = await get_accounts( + filters=Filters(search=search or None, limit=min(limit, MAX_DIRECTORY_USERS)) + ) + + users: list[DirectoryUser] = [] + for account in page.data: + wallets = await get_wallets(account.id) + users.append( + DirectoryUser( + id=account.id, + username=account.username, + email=account.email, + wallets=[ + DirectoryWallet( + id=w.id, name=w.name, balance_msat=w.balance_msat + ) + for w in wallets + ], + ) + ) + + # Readable ordering for a dropdown; core sorts by whatever the page + # query returned, which is account id. + users.sort(key=lambda u: (u.display_name or "").lower()) + return users + + +async def owns_wallet(user_id: str, wallet_id: str) -> bool: + """Whether `wallet_id` belongs to `user_id`. + + The guard that stops a payroll contract from being pointed at a wallet + belonging to somebody other than the selected employee — the id pair + arrives from a form, and nothing else in the write path re-checks it. + """ + wallets = await get_wallets(user_id) + return any(w.id == wallet_id for w in wallets) diff --git a/views_api.py b/views_api.py new file mode 100644 index 0000000..bd8afef --- /dev/null +++ b/views_api.py @@ -0,0 +1,165 @@ +"""Payroll REST API. + +Every route here is gated at the *router* level on `check_super_user`, not +per-endpoint. Payroll reads the account directory and moves money between +wallets the caller does not own, so the gate has to be instance-admin, and +putting it on the router means a new endpoint cannot be added ungated by +forgetting a decorator. + +`require_admin_key` would be the wrong choice and is easy to reach for by +mistake: it authorises writes to the *caller's own* wallet, which any user +has for their own wallets. It is not instance-admin. (See the auth table in +the workspace CLAUDE.md.) +""" + +from http import HTTPStatus + +from fastapi import APIRouter, Depends, HTTPException +from lnbits.core.crud import get_wallet +from lnbits.decorators import check_super_user +from lnbits.utils.exchange_rates import allowed_currencies + +from . import crud +from .accounts import list_directory_users, owns_wallet +from .models import Contract, CreateContract, DirectoryUser, UpdateContract + +payroll_api_router = APIRouter(dependencies=[Depends(check_super_user)]) + + +# --------------------------------------------------------------------------- +# Directory +# --------------------------------------------------------------------------- + + +@payroll_api_router.get("/api/v1/users") +async def api_list_users(search: str | None = None) -> list[DirectoryUser]: + """Accounts + their wallets, for the "select a user" picker.""" + return await list_directory_users(search=search) + + +@payroll_api_router.get("/api/v1/currencies") +async def api_list_currencies() -> list[str]: + """Currencies a contract may be denominated in. "sat" first because a + sat-denominated contract needs no FX at all.""" + return ["sat", *allowed_currencies()] + + +# --------------------------------------------------------------------------- +# Contract validation +# --------------------------------------------------------------------------- + + +async def _validate_terms(data: CreateContract) -> str: + """Check a proposed contract against the world outside its own row, and + return the employee's display name for the snapshot label. + + The employee/wallet pair arrives from a form as two independent ids; + nothing further down the write path re-checks that they belong + together, so this is where a payout gets stopped from being aimed at + somebody else's wallet. + """ + users = {u.id: u for u in await list_directory_users()} + employee = users.get(data.employee_id) + if not employee: + raise HTTPException(HTTPStatus.NOT_FOUND, "Employee account not found.") + + if not await owns_wallet(data.employee_id, data.employee_wallet): + raise HTTPException( + HTTPStatus.BAD_REQUEST, + "Destination wallet does not belong to the selected employee.", + ) + + source = await get_wallet(data.source_wallet) + if not source: + raise HTTPException(HTTPStatus.NOT_FOUND, "Source wallet not found.") + + if data.source_wallet == data.employee_wallet: + raise HTTPException( + HTTPStatus.BAD_REQUEST, "A contract cannot pay a wallet from itself." + ) + + if data.currency != "sat" and data.currency not in allowed_currencies(): + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'." + ) + + return employee.display_name + + +# --------------------------------------------------------------------------- +# Contracts +# --------------------------------------------------------------------------- + + +@payroll_api_router.get("/api/v1/contracts") +async def api_list_contracts() -> list[Contract]: + return await crud.get_contracts() + + +@payroll_api_router.get("/api/v1/contracts/{contract_id}") +async def api_get_contract(contract_id: str) -> Contract: + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + return contract + + +@payroll_api_router.post("/api/v1/contracts", status_code=HTTPStatus.CREATED) +async def api_create_contract(data: CreateContract) -> Contract: + display_name = await _validate_terms(data) + return await crud.create_contract(data, employee_username=display_name) + + +@payroll_api_router.put("/api/v1/contracts/{contract_id}") +async def api_update_contract(contract_id: str, data: UpdateContract) -> Contract: + """Patch the terms of a contract. + + Only amount/currency/frequency/total_periods/label/memo are patchable — + UpdateContract does not carry the recipient, the source wallet or the + start date. Those three are the contract's identity: changing them would + retroactively alter what the payouts already made were *for*, and + changing the start date would silently re-anchor every remaining payday. + Cancel and re-create instead. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + + patch = data.dict(exclude_unset=True, exclude_none=True) + + if "currency" in patch and patch["currency"] != "sat": + if patch["currency"] not in allowed_currencies(): + raise HTTPException( + HTTPStatus.BAD_REQUEST, f"Unsupported currency '{patch['currency']}'." + ) + + # Shrinking a contract below what it has already paid would leave it with + # negative periods remaining; treat the request as an error rather than + # silently completing it. + if "total_periods" in patch and patch["total_periods"] is not None: + if patch["total_periods"] < contract.periods_done: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + f"Contract has already consumed {contract.periods_done} periods.", + ) + + for field, value in patch.items(): + setattr(contract, field, value) + + return await crud.update_contract(contract) + + +@payroll_api_router.delete( + "/api/v1/contracts/{contract_id}", status_code=HTTPStatus.OK +) +async def api_delete_contract(contract_id: str) -> None: + """Hard-delete a contract row. + + This is the "created it by mistake" escape hatch, not the way to stop a + running payroll — deleting throws away the schedule position along with + the row. Cancelling keeps the record. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + await crud.delete_contract(contract_id) From 889751a41fb40096273f8a5b6f8add52b2eb51c1 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:45:21 +0200 Subject: [PATCH 02/10] chore: ignore the lnbits runtime data folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the test suite imports lnbits, which creates ./data/ next to the CWD and drops a real 32-byte .lnbits_auth_key into it. That is precisely the file class behind the 2026-05-14 leak, and it appears without anyone asking for it — so it gets ignored before any feature commit can sweep it up. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index d990a5e..4f0cdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,9 @@ node_modules/ .pytest_cache/ .ruff_cache/ .mypy_cache/ + +# LNbits writes its runtime data folder next to the CWD on import, including +# .lnbits_auth_key — a real instance secret. Importing this package (a test +# run, a REPL) is enough to create it. Never track it. +data/ +.lnbits_auth_key From 55f3dfac9124d023594684cee6526baaa0a4a22e Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:45:27 +0200 Subject: [PATCH 03/10] style: run black and ruff over the committed sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure formatting — import ordering and black's line wrapping. No behaviour change. Kept out of the feature commits so their diffs stay readable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- accounts.py | 4 +--- migrations.py | 10 +++------- models.py | 6 +++--- views_api.py | 4 +--- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/accounts.py b/accounts.py index 0d59673..458aeaa 100644 --- a/accounts.py +++ b/accounts.py @@ -44,9 +44,7 @@ async def list_directory_users( username=account.username, email=account.email, wallets=[ - DirectoryWallet( - id=w.id, name=w.name, balance_msat=w.balance_msat - ) + DirectoryWallet(id=w.id, name=w.name, balance_msat=w.balance_msat) for w in wallets ], ) diff --git a/migrations.py b/migrations.py index 547b587..2967f47 100644 --- a/migrations.py +++ b/migrations.py @@ -16,8 +16,7 @@ schema. async def m001_initial(db): """Payroll contracts — the standing "pay X this much, this often" rows.""" - await db.execute( - f""" + await db.execute(f""" CREATE TABLE payroll.contracts ( id TEXT PRIMARY KEY, employee_id TEXT NOT NULL, @@ -37,16 +36,13 @@ async def m001_initial(db): created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}, updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} ); - """ - ) + """) # The scheduler's hot path is "every contract still eligible to pay". # NOTE: the schema qualifier goes on the INDEX name, not the table — # SQLite attaches the extension DB as schema `payroll`, and # `CREATE INDEX ON schema.table` is a syntax error there. - await db.execute( - "CREATE INDEX payroll.idx_contracts_status ON contracts (status);" - ) + await db.execute("CREATE INDEX payroll.idx_contracts_status ON contracts (status);") # Employee-facing lookups ("show me my payroll lines") filter by account. await db.execute( "CREATE INDEX payroll.idx_contracts_employee ON contracts (employee_id);" diff --git a/models.py b/models.py index 782eaf9..c877200 100644 --- a/models.py +++ b/models.py @@ -56,8 +56,8 @@ class Frequency(str, Enum): class ContractStatus(str, Enum): - active = "active" # eligible for payout on every scheduler tick - paused = "paused" # keeps its schedule position, pays nothing + active = "active" # eligible for payout on every scheduler tick + paused = "paused" # keeps its schedule position, pays nothing completed = "completed" # ran out of periods; terminal cancelled = "cancelled" # stopped by the operator; terminal @@ -93,7 +93,7 @@ class CreateContract(BaseModel): total_periods: int | None = None label: str = "" # display name for the line, e.g. "Alice — dev retainer" - memo: str = "" # payment memo the employee sees; falls back to `label` + memo: str = "" # payment memo the employee sees; falls back to `label` # Periods whose payday already passed before the contract existed. Off by # default: creating a contract with a back-dated start is normally an diff --git a/views_api.py b/views_api.py index bd8afef..76d87c1 100644 --- a/views_api.py +++ b/views_api.py @@ -149,9 +149,7 @@ async def api_update_contract(contract_id: str, data: UpdateContract) -> Contrac return await crud.update_contract(contract) -@payroll_api_router.delete( - "/api/v1/contracts/{contract_id}", status_code=HTTPStatus.OK -) +@payroll_api_router.delete("/api/v1/contracts/{contract_id}", status_code=HTTPStatus.OK) async def api_delete_contract(contract_id: str) -> None: """Hard-delete a contract row. From 99f2131474543b4a415580b9938ec228409c5658 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:45:40 +0200 Subject: [PATCH 04/10] feat: recurring payout scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns a contract into money moving. One permanent task ticks every five minutes and settles whatever each active contract owes; the actual transfer is a plain internal LNbits invoice on the employee's wallet, paid from the source wallet. services.py is split into a pure half and an effectful half on purpose. Paydays are the part of payroll that is easy to get subtly wrong and expensive to get wrong in production, so the schedule math has no DB, no wallets and no clock of its own, and is covered by tests. Decisions worth reviewing: - The n-th payday is a function of start_date and n alone. Advancing a stored date would drift on every late tick and would pin a month-end contract to the 28th forever; anchoring means 31 Jan pays 28 Feb and then 31 Mar. Tested both ways round. - A failed period does not advance the contract's position, and a backlog halts at the first failure so paydays cannot settle out of order. - The sat amount is derived exactly once, by create_invoice, and the value it returns is what gets recorded — never recomputed from amount x rate. - Back-dated start dates skip rather than back-pay by default; a mistyped start date is far more likely than a genuine back-pay request. Explicit `backfill` opts in, and a single tick is capped at 12 periods either way. - Per-contract asyncio lock, with the row re-read under it. Not needed by the scheduler alone, but off-cycle payout paths land mid-tick and double-paying is the worst thing this extension could do. Known gap, addressed by the payout-ledger commit that follows: a failure is retried indefinitely, once per tick, with nothing but a log line to show for it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- __init__.py | 27 ++- docs/operations.md | 81 +++++++++ services.py | 363 +++++++++++++++++++++++++++++++++++++++++ tasks.py | 36 ++++ tests/__init__.py | 0 tests/conftest.py | 43 +++++ tests/test_payout.py | 132 +++++++++++++++ tests/test_schedule.py | 149 +++++++++++++++++ 8 files changed, 829 insertions(+), 2 deletions(-) create mode 100644 docs/operations.md create mode 100644 services.py create mode 100644 tasks.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_payout.py create mode 100644 tests/test_schedule.py diff --git a/__init__.py b/__init__.py index e7c8aaf..521176b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,9 @@ +import asyncio + from fastapi import APIRouter +from loguru import logger from .crud import db -from .views import payroll_generic_router from .views_api import payroll_api_router payroll_static_files = [ @@ -12,12 +14,33 @@ payroll_static_files = [ ] payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"]) -payroll_ext.include_router(payroll_generic_router) payroll_ext.include_router(payroll_api_router) +scheduled_tasks: list[asyncio.Task] = [] + + +def payroll_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: + logger.warning(ex) + + +def payroll_start(): + from lnbits.tasks import create_permanent_unique_task + + from .tasks import scheduler_loop + + scheduled_tasks.append( + create_permanent_unique_task("ext_payroll_scheduler", scheduler_loop) + ) + __all__ = [ "db", "payroll_ext", + "payroll_start", "payroll_static_files", + "payroll_stop", ] diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..93e7c74 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,81 @@ +# Running payroll + +## The scheduler + +One permanent task (`ext_payroll_scheduler`), started with the extension. +It waits ~20s after boot, then every 5 minutes asks each **active** +contract whether it owes a payday. + +Minutes, not seconds, on purpose: a payday is a calendar event, so paying +a few minutes into the day costs nothing, and a tight loop would only +multiply log noise while a contract cannot be funded. The post-boot pass +means an instance that was down over a payday catches up on restart rather +than waiting a full interval. + +Days are **UTC**. An instance whose operators think in a far-eastern or +far-western timezone will see paydays land on what is locally the previous +or next day. + +## How one period is paid + +``` +create_invoice(employee_wallet, amount, currency, internal=True) + │ ← this is also where a fiat contract is priced in sats + ▼ + invoice.amount (msat) ← canonical for this period, recorded as-is + │ + ▼ +pay_invoice(source_wallet, invoice.bolt11) +``` + +A plain internal LNbits wallet-to-wallet transfer. The invoice carries +`extra = {tag: "payroll", contract_id, period, payday}`, so a payroll +transfer is identifiable from the payments list on either wallet. + +The sat amount is derived exactly once, by LNbits' own invoice pricing, and +everything downstream reuses that number. Re-deriving it from +`amount × rate` at any later point would drift — FX moves between quote and +settlement, and rounding accumulates over a year of paydays. + +If a payout fails after the invoice exists, an unpaid internal invoice is +left on the employee's wallet. It was never settled and expires on its own; +that is the deliberate price of not pricing the period a second time just +to run a balance check. + +## What a failure does + +A failed period does **not** advance `periods_done`, so the next tick +retries the same payday. Later periods do not jump the queue: the backlog +halts at the first failure so paydays settle in order. + +The common failure is an underfunded source wallet, and the log line says +so explicitly with both figures: + +``` +payroll: contract a1b2c3d4 period 3 failed: insufficient balance in source +wallet: 12000 sat available, 80000 sat required +``` + +## Back-dated start dates + +Creating a contract with a start date in the past is normally an +*anchoring* choice — "we pay on the 1st" — not a request for back-pay. +Periods whose payday fell before the contract row existed are therefore +**skipped**, and skipping still consumes the period, so the schedule stays +aligned to the anchor. + +Tick `backfill` on the contract to pay them instead. + +Either way a single tick will not fire more than `MAX_CATCH_UP_PERIODS` +(12) periods for one contract, so a mistyped start date cannot turn into a +hundred transfers. + +## Double-payment guard + +Each contract has an in-process `asyncio.Lock`, and `run_due_periods` +re-reads the contract row under it. The scheduler is a single task, so the +lock exists for the off-cycle paths that can land mid-tick. + +This is per-process. Two LNbits processes sharing one database would not +be serialised by it — payroll assumes the single-writer deployment LNbits +itself assumes. diff --git a/services.py b/services.py new file mode 100644 index 0000000..72c3376 --- /dev/null +++ b/services.py @@ -0,0 +1,363 @@ +"""Payroll scheduling and payout. + +Two separable things live here, and the split is deliberate: + +* **Schedule math** (`occurrence_on`, `due_period_indices`) is pure — no + DB, no wallets, no clock of its own. Paydays are the part of payroll that + is easy to get subtly wrong and expensive to get wrong in production, so + it is kept testable in isolation. +* **Payout** (`run_due_periods`) is the effectful half: it turns a due + period into an actual wallet-to-wallet transfer. + +Everything is anchored on `start_date`. The *n*-th payday is a function of +the start date and `n` alone — never of the previous payday, and never of +when the scheduler happened to run. That is what stops a missed tick, a +restart, or a short month from shifting the whole remaining schedule. +""" + +import asyncio +from calendar import monthrange +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone + +from lnbits.core.crud import get_wallet +from lnbits.core.services import create_invoice, pay_invoice +from loguru import logger + +from . import crud +from .models import Contract, ContractStatus, Frequency + +# Frequencies that are an exact number of days: no calendar involved, so no +# clamping is possible or needed. +_DAY_STEP = { + Frequency.daily: 1, + Frequency.weekly: 7, + Frequency.biweekly: 14, +} + +# Frequencies that step whole months and therefore clamp into short months. +_MONTH_STEP = { + Frequency.monthly: 1, + Frequency.quarterly: 3, + Frequency.yearly: 12, +} + +# A single tick will not fire more than this many periods for one contract. +# Protects against a mistyped start date turning into a hundred transfers. +MAX_CATCH_UP_PERIODS = 12 + + +# --------------------------------------------------------------------------- +# Schedule math (pure) +# --------------------------------------------------------------------------- + + +def add_months(anchor: date, months: int) -> date: + """`anchor` shifted by `months`, clamped to the target month's last day. + + 31 Jan + 1 month is 28 Feb (29 in a leap year), because 31 Feb does not + exist. Note this is applied to the *anchor*, not to a previous result: + 31 Jan + 2 months is 31 Mar, not 28 Mar. Payroll anchored on month-end + should keep paying on month-end. + """ + total = anchor.year * 12 + (anchor.month - 1) + months + year, month_index = divmod(total, 12) + month = month_index + 1 + return date(year, month, min(anchor.day, monthrange(year, month)[1])) + + +def occurrence_on(start: date, frequency: Frequency, index: int) -> date: + """The payday for period `index`, counting the start date as period 0.""" + if frequency in _DAY_STEP: + return start + timedelta(days=_DAY_STEP[frequency] * index) + return add_months(start, _MONTH_STEP[frequency] * index) + + +def parse_start_date(contract: Contract) -> date: + return datetime.strptime(contract.start_date, "%Y-%m-%d").date() + + +def next_payday(contract: Contract) -> date | None: + """When this contract pays next, or None if it has no periods left.""" + remaining = contract.periods_remaining + if remaining is not None and remaining <= 0: + return None + return occurrence_on( + parse_start_date(contract), contract.frequency, contract.periods_done + ) + + +def upcoming_paydays(contract: Contract, count: int) -> list[date]: + """The next `count` paydays, truncated by any period cap.""" + start = parse_start_date(contract) + last = contract.total_periods if contract.total_periods is not None else None + indices = range(contract.periods_done, contract.periods_done + count) + return [ + occurrence_on(start, contract.frequency, i) + for i in indices + if last is None or i < last + ] + + +def due_period_indices(contract: Contract, today: date) -> list[int]: + """Every period index that is payable as of `today`, in order. + + Normally this is zero or one entry. It is a list because a contract can + legitimately have a backlog: a back-dated start date, or an instance + that was down over a payday. Returning them all lets the caller decide + what to do with each — `run_due_periods` pays or skips them one at a + time rather than collapsing a backlog into a single surprise transfer. + """ + if contract.status != ContractStatus.active: + return [] + + start = parse_start_date(contract) + cap = contract.total_periods + indices: list[int] = [] + index = contract.periods_done + while cap is None or index < cap: + if occurrence_on(start, contract.frequency, index) > today: + break + indices.append(index) + index += 1 + # A daily contract left unpaid for years would otherwise build an + # unbounded list. Anything beyond this is an operator problem, not + # something to silently drain in one tick. + if len(indices) >= MAX_CATCH_UP_PERIODS: + break + return indices + + +# --------------------------------------------------------------------------- +# Payout (effectful) +# --------------------------------------------------------------------------- + + +@dataclass +class PeriodOutcome: + """What happened to one period. `amount_msat` is the canonical settled + figure, taken from the invoice LNbits actually priced — never + recomputed from amount x rate.""" + + index: int + payday: date + status: str # "paid" | "skipped" | "failed" + detail: str = "" + amount_msat: int | None = None + payment_hash: str | None = None + + @property + def consumed(self) -> bool: + """Whether this period should advance the contract's position. + + Paid and skipped both consume the period. Failed does not — that is + what makes the next tick retry the same payday rather than dropping + it. + """ + return self.status in ("paid", "skipped") + + +# One lock per contract id. The scheduler is a single task, but an operator +# can trigger an off-cycle payout at any moment; without this, a manual run +# landing mid-tick could pay the same period twice. Double-paying is the +# worst thing this extension can do, so the guard is cheap insurance. +_contract_locks: dict[str, asyncio.Lock] = {} + + +def _lock_for(contract_id: str) -> asyncio.Lock: + lock = _contract_locks.get(contract_id) + if lock is None: + lock = asyncio.Lock() + _contract_locks[contract_id] = lock + return lock + + +def payout_memo(contract: Contract, payday: date) -> str: + label = contract.memo or contract.label or "Payroll" + return f"{label} — {payday.isoformat()}" + + +async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome: + """Move one period's money from the source wallet to the employee wallet. + + An internal invoice on the destination wallet, paid from the source + wallet — the standard LNbits wallet-to-wallet transfer. Creating the + invoice is also what prices a fiat contract in sats, so the amount is + converted exactly once, here, and the result is what gets recorded. + + A failure after the invoice exists leaves an unpaid internal invoice on + the employee's wallet. That is harmless — it expires on its own and was + never settled — and it is the price of not pre-computing the sat amount + a second time just to run a balance check. + """ + memo = payout_memo(contract, payday) + extra = { + "tag": "payroll", + "contract_id": contract.id, + "period": index, + "payday": payday.isoformat(), + } + + try: + invoice = await create_invoice( + wallet_id=contract.employee_wallet, + amount=contract.amount, + currency=contract.currency, + memo=memo, + internal=True, + extra=extra, + ) + except Exception as exc: + return PeriodOutcome( + index=index, + payday=payday, + status="failed", + detail=f"could not raise invoice: {exc}", + ) + + # invoice.amount is msat and is now the canonical figure for this period. + amount_msat = invoice.amount + + source = await get_wallet(contract.source_wallet) + if not source: + return PeriodOutcome( + index=index, + payday=payday, + status="failed", + detail="source wallet not found", + amount_msat=amount_msat, + ) + if source.balance_msat < amount_msat: + return PeriodOutcome( + index=index, + payday=payday, + status="failed", + detail=( + f"insufficient balance in source wallet: " + f"{source.balance_msat // 1000} sat available, " + f"{amount_msat // 1000} sat required" + ), + amount_msat=amount_msat, + ) + + try: + await pay_invoice( + wallet_id=contract.source_wallet, + payment_request=invoice.bolt11, + description=memo, + tag="payroll", + extra=extra, + ) + except Exception as exc: + return PeriodOutcome( + index=index, + payday=payday, + status="failed", + detail=f"payment failed: {exc}", + amount_msat=amount_msat, + payment_hash=invoice.payment_hash, + ) + + return PeriodOutcome( + index=index, + payday=payday, + status="paid", + amount_msat=amount_msat, + payment_hash=invoice.payment_hash, + ) + + +async def run_due_periods( + contract: Contract, today: date | None = None +) -> list[PeriodOutcome]: + """Settle every period this contract owes as of `today`. + + Stops at the first failure so a backlog cannot pay periods out of order: + if period 3 could not be funded, period 4 waits for it. Both will be + retried on the next tick, because a failed period does not advance the + contract's position. + """ + today = today or datetime.now(timezone.utc).date() + + async with _lock_for(contract.id): + # Re-read under the lock: a manual run may have moved the position + # since the caller loaded this row. + fresh = await crud.get_contract(contract.id) + if not fresh: + return [] + contract = fresh + + outcomes: list[PeriodOutcome] = [] + for index in due_period_indices(contract, today): + payday = occurrence_on( + parse_start_date(contract), contract.frequency, index + ) + outcome = await _settle(contract, index, payday) + outcomes.append(outcome) + if not outcome.consumed: + break + contract.periods_done = index + 1 + + if outcomes: + _maybe_complete(contract) + await crud.update_contract(contract) + + return outcomes + + +async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome: + """Pay a period, or skip it as back-dated. + + A contract created with a start date in the past is normally an + *anchoring* choice — "we pay on the 1st" — not a request for back-pay. + Periods whose payday fell before the contract existed are therefore + skipped unless the operator explicitly asked to backfill, which keeps a + new contract from firing months of transfers on its first tick. + """ + if not contract.backfill and payday < contract.created_at.date(): + logger.info( + f"payroll: contract {contract.id} period {index} ({payday}) " + "predates the contract; skipping (backfill is off)" + ) + return PeriodOutcome( + index=index, + payday=payday, + status="skipped", + detail="payday predates the contract and backfill is off", + ) + + outcome = await pay_period(contract, index, payday) + if outcome.status == "paid": + logger.success( + f"payroll: contract {contract.id} period {index} paid " + f"{(outcome.amount_msat or 0) // 1000} sat to " + f"{contract.employee_wallet}" + ) + else: + logger.warning( + f"payroll: contract {contract.id} period {index} " + f"{outcome.status}: {outcome.detail}" + ) + return outcome + + +def _maybe_complete(contract: Contract) -> None: + if ( + contract.total_periods is not None + and contract.periods_done >= contract.total_periods + ): + contract.status = ContractStatus.completed + logger.info( + f"payroll: contract {contract.id} completed " + f"({contract.periods_done} periods)" + ) + + +async def tick(today: date | None = None) -> None: + """One scheduler pass over every active contract.""" + contracts = await crud.get_contracts_by_status(ContractStatus.active) + for contract in contracts: + try: + await run_due_periods(contract, today) + except Exception as exc: + logger.error(f"payroll: contract {contract.id} tick failed: {exc}") diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..ceb0d48 --- /dev/null +++ b/tasks.py @@ -0,0 +1,36 @@ +"""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: + logger.error(f"payroll: scheduler tick failed: {exc}") + await asyncio.sleep(TICK_SECONDS) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3bd7a28 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""Test fixtures for payroll. + +The schedule math is pure, so most tests need nothing but a `Contract` +instance — no LNbits DB, no wallets, no event loop. That is the point of +keeping `services` split into a pure half and an effectful half. +""" + +from datetime import datetime, timezone + +from ..models import Contract, ContractStatus, Frequency + + +def make_contract( + *, + contract_id: str = "c1", + start_date: str = "2026-01-15", + frequency: Frequency = Frequency.monthly, + total_periods: int | None = 12, + periods_done: int = 0, + status: ContractStatus = ContractStatus.active, + amount: float = 1000, + currency: str = "sat", + backfill: bool = False, + created_at: str = "2026-01-01", +) -> Contract: + return Contract( + id=contract_id, + employee_id="employee-account", + employee_username="alice", + employee_wallet="wallet-employee", + source_wallet="wallet-treasury", + amount=amount, + currency=currency, + frequency=frequency, + start_date=start_date, + total_periods=total_periods, + periods_done=periods_done, + status=status, + backfill=backfill, + created_at=datetime.strptime(created_at, "%Y-%m-%d").replace( + tzinfo=timezone.utc + ), + ) diff --git a/tests/test_payout.py b/tests/test_payout.py new file mode 100644 index 0000000..8a1399f --- /dev/null +++ b/tests/test_payout.py @@ -0,0 +1,132 @@ +"""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 +from ..services import PeriodOutcome, 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 = "paid" + + 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_pay_period(contract, index, payday): + stub.attempted.append(index) + return PeriodOutcome( + index=index, + payday=payday, + status=stub.outcome_status, + detail="" if stub.outcome_status == "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, "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 [o.status for o in outcomes] == ["skipped"] * 4 + ["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 [o.status for o in outcomes] == ["paid", "paid", "paid"] + 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 = "failed" + + outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20))) + + assert [o.status for o in outcomes] == ["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 == [] diff --git a/tests/test_schedule.py b/tests/test_schedule.py new file mode 100644 index 0000000..c795a2a --- /dev/null +++ b/tests/test_schedule.py @@ -0,0 +1,149 @@ +"""Schedule math. + +These are the cases that make month-anchored payroll wrong in practice: +month-end clamping, clamping that must not become permanent, leap days, and +a schedule that must not drift no matter how late the scheduler runs. +""" + +from datetime import date + +import pytest + +from ..models import ContractStatus, Frequency +from ..services import ( + MAX_CATCH_UP_PERIODS, + add_months, + due_period_indices, + next_payday, + occurrence_on, + upcoming_paydays, +) +from .conftest import make_contract + +# --- month arithmetic ------------------------------------------------------ + + +def test_add_months_clamps_into_a_short_month(): + assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28) + + +def test_clamping_is_not_permanent(): + """The whole reason paydays are computed from the anchor and not from the + previous payday: 31 Jan must pay 28 Feb and then *31* Mar, not 28 Mar.""" + anchor = date(2026, 1, 31) + assert add_months(anchor, 1) == date(2026, 2, 28) + assert add_months(anchor, 2) == date(2026, 3, 31) + assert add_months(anchor, 3) == date(2026, 4, 30) + assert add_months(anchor, 4) == date(2026, 5, 31) + + +def test_add_months_crosses_year_boundaries(): + assert add_months(date(2026, 11, 30), 3) == date(2027, 2, 28) + assert add_months(date(2026, 3, 15), -4) == date(2025, 11, 15) + + +def test_leap_day_anchor_clamps_in_common_years(): + leap = date(2028, 2, 29) + assert add_months(leap, 12) == date(2029, 2, 28) + assert add_months(leap, 48) == date(2032, 2, 29) + + +# --- occurrences ----------------------------------------------------------- + + +@pytest.mark.parametrize( + ("frequency", "index", "expected"), + [ + (Frequency.daily, 0, date(2026, 1, 15)), + (Frequency.daily, 20, date(2026, 2, 4)), + (Frequency.weekly, 3, date(2026, 2, 5)), + (Frequency.biweekly, 2, date(2026, 2, 12)), + (Frequency.monthly, 2, date(2026, 3, 15)), + (Frequency.quarterly, 2, date(2026, 7, 15)), + (Frequency.yearly, 2, date(2028, 1, 15)), + ], +) +def test_occurrence_on(frequency, index, expected): + assert occurrence_on(date(2026, 1, 15), frequency, index) == expected + + +def test_period_zero_is_the_start_date_for_every_frequency(): + start = date(2026, 6, 30) + for frequency in Frequency: + assert occurrence_on(start, frequency, 0) == start + + +def test_schedule_does_not_drift_over_a_year(): + """A monthly contract must land on the same day twelve months later — + incrementally advancing a stored date is what would break this.""" + start = date(2026, 1, 15) + assert occurrence_on(start, Frequency.monthly, 12) == date(2027, 1, 15) + + +# --- due periods ----------------------------------------------------------- + + +def test_nothing_is_due_before_the_start_date(): + contract = make_contract(start_date="2026-03-01") + assert due_period_indices(contract, date(2026, 2, 28)) == [] + + +def test_the_start_date_itself_is_due(): + contract = make_contract(start_date="2026-03-01") + assert due_period_indices(contract, date(2026, 3, 1)) == [0] + + +def test_a_backlog_returns_every_missed_period_in_order(): + contract = make_contract(start_date="2026-01-15", frequency=Frequency.monthly) + assert due_period_indices(contract, date(2026, 4, 20)) == [0, 1, 2, 3] + + +def test_periods_already_done_are_not_due_again(): + contract = make_contract(start_date="2026-01-15", periods_done=3) + assert due_period_indices(contract, date(2026, 4, 20)) == [3] + + +def test_due_periods_stop_at_the_period_cap(): + contract = make_contract(start_date="2026-01-15", total_periods=2) + assert due_period_indices(contract, date(2026, 12, 31)) == [0, 1] + + +def test_catch_up_is_capped_for_open_ended_contracts(): + """A daily contract with a badly back-dated start must not try to fire + hundreds of transfers in one tick.""" + contract = make_contract( + start_date="2020-01-01", frequency=Frequency.daily, total_periods=None + ) + assert len(due_period_indices(contract, date(2026, 1, 1))) == MAX_CATCH_UP_PERIODS + + +@pytest.mark.parametrize( + "status", + [ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed], +) +def test_only_active_contracts_are_due(status): + contract = make_contract(start_date="2026-01-15", status=status) + assert due_period_indices(contract, date(2026, 6, 1)) == [] + + +# --- previews -------------------------------------------------------------- + + +def test_next_payday_follows_the_position(): + contract = make_contract(start_date="2026-01-31", periods_done=1) + assert next_payday(contract) == date(2026, 2, 28) + + +def test_next_payday_is_none_once_exhausted(): + contract = make_contract(total_periods=3, periods_done=3) + assert next_payday(contract) is None + + +def test_upcoming_paydays_are_truncated_by_the_period_cap(): + contract = make_contract(start_date="2026-01-15", total_periods=3, periods_done=1) + assert upcoming_paydays(contract, 10) == [date(2026, 2, 15), date(2026, 3, 15)] + + +def test_upcoming_paydays_are_unbounded_for_open_ended_contracts(): + contract = make_contract(start_date="2026-01-15", total_periods=None) + assert len(upcoming_paydays(contract, 24)) == 24 From 95bfa86f790807c3edf81cd07999f3be81b8ba98 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:47:07 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20contract=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20pause,=20resume,=20cancel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beyond create and delete, a payroll line needs to be stoppable without being erased. Three transitions, expressed as pure functions on the model so the rules are testable without a DB, and mapped to 409 at the API boundary — a refused transition is a well-formed request that the contract's current state declines. The decision with money attached is what resume does about the paydays that fell while the contract was paused. It skips them: a pause is a decision not to pay, and resuming into an unannounced multi-period transfer is the opposite of what "resume" implies. `catch_up=true` opts into paying them, for a pause that was an operational hold rather than a call about the money. Cancel is now the way to stop a running payroll; DELETE stays as the "created it by mistake" escape hatch, and the docstrings say which is which, because deleting discards the schedule position and the record that the contract ever existed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- services.py | 77 ++++++++++++++++++++++++++++- tests/test_lifecycle.py | 107 ++++++++++++++++++++++++++++++++++++++++ views_api.py | 56 ++++++++++++++++++++- 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 tests/test_lifecycle.py diff --git a/services.py b/services.py index 72c3376..d54df83 100644 --- a/services.py +++ b/services.py @@ -25,7 +25,7 @@ from lnbits.core.services import create_invoice, pay_invoice from loguru import logger from . import crud -from .models import Contract, ContractStatus, Frequency +from .models import TERMINAL_STATUSES, Contract, ContractStatus, Frequency # Frequencies that are an exact number of days: no calendar involved, so no # clamping is possible or needed. @@ -361,3 +361,78 @@ async def tick(today: date | None = None) -> None: await run_due_periods(contract, today) except Exception as exc: logger.error(f"payroll: contract {contract.id} tick failed: {exc}") + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +class LifecycleError(ValueError): + """An illegal status transition. Mapped to 409 at the API boundary.""" + + +def fast_forward_index(contract: Contract, today: date) -> int: + """The first period index whose payday is not already in the past.""" + start = parse_start_date(contract) + cap = contract.total_periods + index = contract.periods_done + while (cap is None or index < cap) and occurrence_on( + start, contract.frequency, index + ) < today: + index += 1 + return index + + +def pause(contract: Contract) -> Contract: + if contract.status != ContractStatus.active: + raise LifecycleError( + f"Only an active contract can be paused (is {contract.status.value})." + ) + contract.status = ContractStatus.paused + return contract + + +def resume( + contract: Contract, today: date | None = None, catch_up: bool = False +) -> Contract: + """Put a paused contract back to work. + + By default the paydays that fell during the pause are *not* paid: a + pause is a decision not to pay them, and resuming into a surprise + multi-period transfer is the opposite of what an operator asking to + resume expects. The contract's position is fast-forwarded to the next + payday on or after today instead. + + `catch_up=True` opts into paying them, for the case where the pause was + an operational hold rather than a decision about the money. + """ + if contract.status != ContractStatus.paused: + raise LifecycleError( + f"Only a paused contract can be resumed (is {contract.status.value})." + ) + + contract.status = ContractStatus.active + if not catch_up: + today = today or datetime.now(timezone.utc).date() + skipped_to = fast_forward_index(contract, today) + if skipped_to != contract.periods_done: + logger.info( + f"payroll: contract {contract.id} resumed, skipping " + f"{skipped_to - contract.periods_done} payday(s) missed while paused" + ) + contract.periods_done = skipped_to + _maybe_complete(contract) + return contract + + +def cancel(contract: Contract) -> Contract: + """Stop a contract for good, keeping the row and its history. + + This — not DELETE — is how a running payroll is stopped: deleting throws + away the schedule position and the record that the contract ever existed. + """ + if contract.status in TERMINAL_STATUSES: + raise LifecycleError(f"Contract is already {contract.status.value}.") + contract.status = ContractStatus.cancelled + return contract diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..c8ccd57 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,107 @@ +"""Contract lifecycle transitions. + +Pure functions on the model, so no stubs needed. What matters here is which +moves are refused and — the one with money attached — what resuming does to +the paydays that fell while the contract was paused. +""" + +from datetime import date + +import pytest + +from ..models import ContractStatus +from ..services import LifecycleError, cancel, pause, resume +from .conftest import make_contract + +# --- pause ----------------------------------------------------------------- + + +def test_pause_keeps_the_schedule_position(): + contract = make_contract(periods_done=3) + pause(contract) + assert contract.status == ContractStatus.paused + assert contract.periods_done == 3 + + +@pytest.mark.parametrize( + "status", + [ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed], +) +def test_only_an_active_contract_can_be_paused(status): + with pytest.raises(LifecycleError): + pause(make_contract(status=status)) + + +# --- resume ---------------------------------------------------------------- + + +def test_resume_skips_the_paydays_missed_while_paused(): + """A pause is a decision not to pay. Resuming in April on a monthly + contract paused in January must not fire three transfers.""" + contract = make_contract( + start_date="2026-01-15", periods_done=0, status=ContractStatus.paused + ) + + resume(contract, today=date(2026, 4, 10)) + + assert contract.status == ContractStatus.active + assert contract.periods_done == 3 # Jan/Feb/Mar written off + # 15 April is still ahead, so it is the next one paid. + + +def test_resume_with_catch_up_pays_the_missed_paydays(): + contract = make_contract( + start_date="2026-01-15", periods_done=0, status=ContractStatus.paused + ) + + resume(contract, today=date(2026, 4, 10), catch_up=True) + + assert contract.periods_done == 0 # the backlog is left for the scheduler + + +def test_resume_does_not_skip_a_payday_falling_today(): + contract = make_contract( + start_date="2026-01-15", periods_done=0, status=ContractStatus.paused + ) + + resume(contract, today=date(2026, 1, 15)) + + assert contract.periods_done == 0 + + +def test_resuming_past_the_end_completes_the_contract(): + contract = make_contract( + start_date="2026-01-15", + total_periods=2, + periods_done=0, + status=ContractStatus.paused, + ) + + resume(contract, today=date(2027, 1, 1)) + + assert contract.status == ContractStatus.completed + + +@pytest.mark.parametrize( + "status", + [ContractStatus.active, ContractStatus.cancelled, ContractStatus.completed], +) +def test_only_a_paused_contract_can_be_resumed(status): + with pytest.raises(LifecycleError): + resume(make_contract(status=status), today=date(2026, 6, 1)) + + +# --- cancel ---------------------------------------------------------------- + + +@pytest.mark.parametrize("status", [ContractStatus.active, ContractStatus.paused]) +def test_cancel_from_a_live_status(status): + contract = make_contract(status=status) + cancel(contract) + assert contract.status == ContractStatus.cancelled + + +@pytest.mark.parametrize("status", [ContractStatus.cancelled, ContractStatus.completed]) +def test_terminal_contracts_cannot_be_cancelled_again(status): + with pytest.raises(LifecycleError): + cancel(make_contract(status=status)) diff --git a/views_api.py b/views_api.py index 76d87c1..9f6a83c 100644 --- a/views_api.py +++ b/views_api.py @@ -19,7 +19,7 @@ from lnbits.core.crud import get_wallet from lnbits.decorators import check_super_user from lnbits.utils.exchange_rates import allowed_currencies -from . import crud +from . import crud, services from .accounts import list_directory_users, owns_wallet from .models import Contract, CreateContract, DirectoryUser, UpdateContract @@ -161,3 +161,57 @@ async def api_delete_contract(contract_id: str) -> None: if not contract: raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") await crud.delete_contract(contract_id) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +async def _transition(contract_id: str, apply) -> Contract: + """Load a contract, apply a services-layer transition, persist it. + + The transitions themselves are pure functions on the model — they raise + LifecycleError for an illegal move, which becomes a 409 here rather than + a 400, because the request is well-formed and it is the contract's + current state that refuses it. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + try: + apply(contract) + except services.LifecycleError as exc: + raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc + return await crud.update_contract(contract) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/pause") +async def api_pause_contract(contract_id: str) -> Contract: + """Stop paying without losing the schedule position.""" + return await _transition(contract_id, services.pause) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/resume") +async def api_resume_contract(contract_id: str, catch_up: bool = False) -> Contract: + """Put a paused contract back to work. + + Paydays missed during the pause are skipped by default — a pause is a + decision not to pay them, and resuming into an unannounced multi-period + transfer is the opposite of what "resume" implies. `catch_up=true` pays + them, for a pause that was an operational hold rather than a decision + about the money. + """ + return await _transition( + contract_id, lambda c: services.resume(c, catch_up=catch_up) + ) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/cancel") +async def api_cancel_contract(contract_id: str) -> Contract: + """Stop a contract for good, keeping the row and its history. + + The right way to end a payroll line. DELETE is the "created it by + mistake" escape hatch and discards the record entirely. + """ + return await _transition(contract_id, services.cancel) From 8b054d27ea33234a171f26024e3b0694af591ad7 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:49:46 +0200 Subject: [PATCH 06/10] feat: payout ledger with bounded retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- __init__.py | 2 +- crud.py | 53 +++++++++++++++++++- docs/operations.md | 26 ++++++++++ migrations.py | 41 +++++++++++++++ models.py | 53 ++++++++++++++++++++ services.py | 116 ++++++++++++++++++++++++++++++++++++------- tasks.py | 2 +- tests/test_payout.py | 96 ++++++++++++++++++++++++++++++++--- views_api.py | 30 ++++++++++- 9 files changed, 389 insertions(+), 30 deletions(-) diff --git a/__init__.py b/__init__.py index 521176b..a60bb29 100644 --- a/__init__.py +++ b/__init__.py @@ -23,7 +23,7 @@ def payroll_stop(): for task in scheduled_tasks: try: task.cancel() - except Exception as ex: + except Exception as ex: # shutdown must not raise logger.warning(ex) diff --git a/crud.py b/crud.py index 71c98a6..2c76717 100644 --- a/crud.py +++ b/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 diff --git a/docs/operations.md b/docs/operations.md index 93e7c74..a8e06da 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,6 +56,32 @@ payroll: contract a1b2c3d4 period 3 failed: insufficient balance in source wallet: 12000 sat available, 80000 sat required ``` +Retries are bounded. After `MAX_PERIOD_ATTEMPTS` (5) failures on the *same* +period, the contract is **paused** and the operator has to act. Pausing +rather than abandoning the period is the point: a payday that cannot be +funded is a fact somebody needs to see, and silently dropping it is the one +outcome payroll must never produce. Because pausing does not advance the +position, resuming after topping up the source wallet retries that same +payday. + +## The payout ledger + +Every attempt — paid, skipped *and* failed — is written to +`payroll.payouts`. `GET /payroll/api/v1/payouts` returns them newest first, +filterable by `contract_id` and `status`. "Why did nobody get paid on the +1st" is the question the ledger exists to answer, which is why failures are +in it rather than only in the log. + +Rows are self-describing: each one copies the terms in force at the time +(amount, currency, both wallet ids) rather than pointing at the contract, +so a payout still reads correctly after its contract is edited or deleted. +There is deliberately no foreign key to `contracts` — deleting a contract +must not take its history with it. + +The attempt counter is derived by counting a period's prior failures in the +ledger, not from a column on the contract, so it survives a restart and the +rows that produced the number are right there to audit. + ## Back-dated start dates Creating a contract with a start date in the past is normally an diff --git a/migrations.py b/migrations.py index 2967f47..282e1fc 100644 --- a/migrations.py +++ b/migrations.py @@ -47,3 +47,44 @@ async def m001_initial(db): await db.execute( "CREATE INDEX payroll.idx_contracts_employee ON contracts (employee_id);" ) + + +async def m002_payouts(db): + """The payout ledger — one row per attempt at one period. + + Rows are self-describing (they carry the contract terms in force at the + time) so a payout still reads correctly after its contract is edited or + deleted. There is deliberately no foreign key to `contracts` for the + same reason: deleting a contract must not take its history with it. + """ + + await db.execute(f""" + CREATE TABLE payroll.payouts ( + id TEXT PRIMARY KEY, + contract_id TEXT NOT NULL, + period_index INTEGER NOT NULL, + payday TEXT NOT NULL, + status TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 1, + amount_msat {db.big_int}, + amount REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'sat', + employee_id TEXT NOT NULL DEFAULT '', + employee_wallet TEXT NOT NULL DEFAULT '', + source_wallet TEXT NOT NULL DEFAULT '', + payment_hash TEXT, + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now} + ); + """) + + # Counting a period's prior failures runs on every failed attempt. + await db.execute( + "CREATE INDEX payroll.idx_payouts_period " + "ON payouts (contract_id, period_index);" + ) + # The employee-facing payslip view filters on the destination wallet. + await db.execute( + "CREATE INDEX payroll.idx_payouts_employee_wallet " + "ON payouts (employee_wallet);" + ) diff --git a/models.py b/models.py index c877200..00edf83 100644 --- a/models.py +++ b/models.py @@ -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 diff --git a/services.py b/services.py index d54df83..3d4ede0 100644 --- a/services.py +++ b/services.py @@ -22,10 +22,18 @@ from datetime import date, datetime, timedelta, timezone from lnbits.core.crud import get_wallet from lnbits.core.services import create_invoice, pay_invoice +from lnbits.helpers import urlsafe_short_hash from loguru import logger from . import crud -from .models import TERMINAL_STATUSES, Contract, ContractStatus, Frequency +from .models import ( + TERMINAL_STATUSES, + Contract, + ContractStatus, + Frequency, + Payout, + PayoutStatus, +) # Frequencies that are an exact number of days: no calendar involved, so no # clamping is possible or needed. @@ -46,6 +54,12 @@ _MONTH_STEP = { # Protects against a mistyped start date turning into a hundred transfers. MAX_CATCH_UP_PERIODS = 12 +# How many times one period may fail before the contract is paused for the +# operator to look at. Retrying an underfunded wallet forever is not +# resilience, it is a log the operator learns to ignore — and a missed +# payday deserves to be visible in the UI, not buried in journalctl. +MAX_PERIOD_ATTEMPTS = 5 + # --------------------------------------------------------------------------- # Schedule math (pure) @@ -141,7 +155,7 @@ class PeriodOutcome: index: int payday: date - status: str # "paid" | "skipped" | "failed" + status: PayoutStatus detail: str = "" amount_msat: int | None = None payment_hash: str | None = None @@ -154,7 +168,7 @@ class PeriodOutcome: what makes the next tick retry the same payday rather than dropping it. """ - return self.status in ("paid", "skipped") + return self.status in (PayoutStatus.paid, PayoutStatus.skipped) # One lock per contract id. The scheduler is a single task, but an operator @@ -207,11 +221,13 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc internal=True, extra=extra, ) + # Broad: pricing, wallet limits and funding-source errors all surface + # here, and every one of them is a failed period rather than a crash. except Exception as exc: return PeriodOutcome( index=index, payday=payday, - status="failed", + status=PayoutStatus.failed, detail=f"could not raise invoice: {exc}", ) @@ -223,7 +239,7 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc return PeriodOutcome( index=index, payday=payday, - status="failed", + status=PayoutStatus.failed, detail="source wallet not found", amount_msat=amount_msat, ) @@ -231,7 +247,7 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc return PeriodOutcome( index=index, payday=payday, - status="failed", + status=PayoutStatus.failed, detail=( f"insufficient balance in source wallet: " f"{source.balance_msat // 1000} sat available, " @@ -248,11 +264,11 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc tag="payroll", extra=extra, ) - except Exception as exc: + except Exception as exc: # a failed payment is an expected outcome return PeriodOutcome( index=index, payday=payday, - status="failed", + status=PayoutStatus.failed, detail=f"payment failed: {exc}", amount_msat=amount_msat, payment_hash=invoice.payment_hash, @@ -261,7 +277,7 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc return PeriodOutcome( index=index, payday=payday, - status="paid", + status=PayoutStatus.paid, amount_msat=amount_msat, payment_hash=invoice.payment_hash, ) @@ -269,9 +285,12 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc async def run_due_periods( contract: Contract, today: date | None = None -) -> list[PeriodOutcome]: +) -> list[Payout]: """Settle every period this contract owes as of `today`. + Every attempt — paid, skipped or failed — is written to the ledger, and + the ledger rows are what this returns. + Stops at the first failure so a backlog cannot pay periods out of order: if period 3 could not be funded, period 4 waits for it. Both will be retried on the next tick, because a failed period does not advance the @@ -287,22 +306,79 @@ async def run_due_periods( return [] contract = fresh - outcomes: list[PeriodOutcome] = [] + payouts: list[Payout] = [] for index in due_period_indices(contract, today): payday = occurrence_on( parse_start_date(contract), contract.frequency, index ) outcome = await _settle(contract, index, payday) - outcomes.append(outcome) + payout = await _record(contract, outcome) + payouts.append(payout) + if not outcome.consumed: + _maybe_pause_after_repeated_failure(contract, payout) break contract.periods_done = index + 1 - if outcomes: + if payouts: _maybe_complete(contract) await crud.update_contract(contract) - return outcomes + return payouts + + +async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout: + """Write one attempt to the ledger. + + The attempt number is derived by counting this period's prior failures + in the ledger itself rather than from a counter on the contract, so it + survives a restart and the rows that produced it are right there to + audit. A paid or skipped period is never retried, so it is always + attempt 1 as far as that outcome is concerned. + """ + attempt = 1 + if outcome.status == PayoutStatus.failed: + attempt = await crud.count_period_failures(contract.id, outcome.index) + 1 + + return await crud.create_payout( + Payout( + id=urlsafe_short_hash()[:12], + contract_id=contract.id, + period_index=outcome.index, + payday=outcome.payday.isoformat(), + status=PayoutStatus(outcome.status), + attempt=attempt, + amount_msat=outcome.amount_msat, + # Terms in force at the time — a ledger row has to still read + # correctly after the contract is edited or deleted. + amount=contract.amount, + currency=contract.currency, + employee_id=contract.employee_id, + employee_wallet=contract.employee_wallet, + source_wallet=contract.source_wallet, + payment_hash=outcome.payment_hash, + detail=outcome.detail, + ) + ) + + +def _maybe_pause_after_repeated_failure(contract: Contract, payout: Payout) -> None: + """Give up retrying a period and hand it to the operator. + + The contract is paused rather than the period abandoned: a payday that + cannot be funded is a fact somebody needs to act on, and silently + dropping it is the one outcome payroll must never produce. Resuming + after fixing the funding retries the same period, because pausing did + not advance the position. + """ + if payout.attempt < MAX_PERIOD_ATTEMPTS: + return + contract.status = ContractStatus.paused + logger.error( + f"payroll: contract {contract.id} paused after {payout.attempt} failed " + f"attempts at period {payout.period_index} ({payout.payday}): " + f"{payout.detail}" + ) async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome: @@ -322,12 +398,12 @@ async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome return PeriodOutcome( index=index, payday=payday, - status="skipped", + status=PayoutStatus.skipped, detail="payday predates the contract and backfill is off", ) outcome = await pay_period(contract, index, payday) - if outcome.status == "paid": + if outcome.status == PayoutStatus.paid: logger.success( f"payroll: contract {contract.id} period {index} paid " f"{(outcome.amount_msat or 0) // 1000} sat to " @@ -336,12 +412,16 @@ async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome else: logger.warning( f"payroll: contract {contract.id} period {index} " - f"{outcome.status}: {outcome.detail}" + f"{outcome.status.value}: {outcome.detail}" ) return outcome def _maybe_complete(contract: Contract) -> None: + # Only an active contract completes: a period that just exhausted the + # retry budget has paused this one, and that has to stick. + if contract.status != ContractStatus.active: + return if ( contract.total_periods is not None and contract.periods_done >= contract.total_periods @@ -359,7 +439,7 @@ async def tick(today: date | None = None) -> None: for contract in contracts: try: await run_due_periods(contract, today) - except Exception as exc: + except Exception as exc: # one bad row must not stop the rest of payroll logger.error(f"payroll: contract {contract.id} tick failed: {exc}") diff --git a/tasks.py b/tasks.py index ceb0d48..f64910f 100644 --- a/tasks.py +++ b/tasks.py @@ -31,6 +31,6 @@ async def scheduler_loop(): while True: try: await services.tick() - except Exception as exc: + except Exception as exc: # the loop must outlive any single tick logger.error(f"payroll: scheduler tick failed: {exc}") await asyncio.sleep(TICK_SECONDS) diff --git a/tests/test_payout.py b/tests/test_payout.py index 8a1399f..9ceaea1 100644 --- a/tests/test_payout.py +++ b/tests/test_payout.py @@ -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 diff --git a/views_api.py b/views_api.py index 9f6a83c..5d977f0 100644 --- a/views_api.py +++ b/views_api.py @@ -21,7 +21,14 @@ from lnbits.utils.exchange_rates import allowed_currencies from . import crud, services from .accounts import list_directory_users, owns_wallet -from .models import Contract, CreateContract, DirectoryUser, UpdateContract +from .models import ( + Contract, + CreateContract, + DirectoryUser, + Payout, + PayoutStatus, + UpdateContract, +) payroll_api_router = APIRouter(dependencies=[Depends(check_super_user)]) @@ -215,3 +222,24 @@ async def api_cancel_contract(contract_id: str) -> Contract: mistake" escape hatch and discards the record entirely. """ return await _transition(contract_id, services.cancel) + + +# --------------------------------------------------------------------------- +# Payout ledger +# --------------------------------------------------------------------------- + + +@payroll_api_router.get("/api/v1/payouts") +async def api_list_payouts( + contract_id: str | None = None, + status: PayoutStatus | None = None, + limit: int = 200, +) -> list[Payout]: + """Every payout attempt, newest first. + + Includes failures and skips, not just successes — "why did nobody get + paid on the 1st" is the question this endpoint exists to answer. + """ + return await crud.get_payouts( + contract_id=contract_id, status=status, limit=min(limit, 1000) + ) From ee10b2e04d27beab4968701d4d4a59e02409b535 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:51:20 +0200 Subject: [PATCH 07/10] feat: schedule preview and off-cycle payouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things an operator needs that the scheduler alone does not give them. Preview draws a contract's calendar from loose terms — start date, frequency, period count — with no employee or wallet required, because the schedule is what an operator wants to sanity-check first and a mistyped start date is cheapest to fix before anything is saved. The same shape is available for a live contract, from its current position. Both return `ends_on`: "12 monthly payments from 15 Jan" is far easier to verify against "ends 15 Dec" than against a list of twelve dates. pay-now settles the next period immediately and consumes it. Deliberately one endpoint rather than two: "run it now, don't wait for the tick" and "pay it early" are the same operation and differ only in whether today happens to be the payday. It bypasses the back-dated skip — that guard exists to stop a new contract firing surprise back-pay, and an operator explicitly asking to pay is not a surprise — and it takes the same per-contract lock as the scheduler, which is what that lock was added for. The ledger row is returned even on failure, since a manual payout that did not land is exactly when you want the reason. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- docs/operations.md | 29 +++++++++++++ models.py | 38 +++++++++++++++++ services.py | 96 ++++++++++++++++++++++++++++++++++++++---- tests/test_payout.py | 49 ++++++++++++++++++++- tests/test_schedule.py | 35 +++++++++++++++ views_api.py | 75 +++++++++++++++++++++++++++++++++ 6 files changed, 313 insertions(+), 9 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index a8e06da..b0ad578 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -105,3 +105,32 @@ lock exists for the off-cycle paths that can land mid-tick. This is per-process. Two LNbits processes sharing one database would not be serialised by it — payroll assumes the single-writer deployment LNbits itself assumes. + +## Previewing a schedule + +`POST /payroll/api/v1/schedule/preview` draws a calendar from loose terms +(`start_date`, `frequency`, `total_periods`) — no employee or wallet +required, because the schedule is usually the thing worth sanity-checking +first, and a mistyped start date is cheapest to fix before anything is +saved. `GET /payroll/api/v1/contracts/{id}/schedule` does the same for a +live contract, from its current position. + +Both return `ends_on`, the contract's last payday. "12 monthly payments +from 15 Jan" is much easier to check against "ends 15 Dec" than against a +list of twelve dates. + +## Paying off-cycle + +`POST /payroll/api/v1/contracts/{id}/pay-now` settles the contract's next +period immediately, whatever the calendar says, and consumes it. + +One endpoint covers both "run it now" (don't wait for the tick) and "pay it +early", because they are the same operation — they differ only in whether +today happens to be the payday. It bypasses the back-dated skip: that guard +exists to stop a *new* contract firing surprise back-pay, and an operator +explicitly asking to pay a period is not a surprise. + +It is recorded in the ledger like any other payout, with the early payment +noted in `detail`, and the ledger row is returned even when the payout +failed — a manual payout that did not land is exactly when you want to know +why. diff --git a/models.py b/models.py index 00edf83..15ccaac 100644 --- a/models.py +++ b/models.py @@ -244,3 +244,41 @@ class Payout(BaseModel): @property def amount_sat(self) -> int | None: return None if self.amount_msat is None else self.amount_msat // 1000 + + +# --------------------------------------------------------------------------- +# Schedule preview +# --------------------------------------------------------------------------- + + +class SchedulePreviewRequest(BaseModel): + """Enough of a contract to draw its calendar, with none of its identity. + + Deliberately not a `CreateContract`: previewing must work before an + employee or a wallet has been chosen, since the schedule is usually the + thing the operator wants to sanity-check first. + """ + + start_date: str # YYYY-MM-DD + frequency: Frequency = Frequency.monthly + total_periods: int | None = None + count: int = 12 # how many paydays to draw + + @validator("start_date") + def _valid_date(cls, v: str) -> str: + datetime.strptime(v, "%Y-%m-%d") + return v + + @validator("count") + def _sane_count(cls, v: int) -> int: + return max(1, min(v, 120)) + + +class SchedulePreview(BaseModel): + paydays: list[str] # YYYY-MM-DD, in order + total_periods: int | None = None + # The contract's last payday, or None for an open-ended one. Worth + # surfacing on its own: "12 monthly payments from 15 Jan" is much easier + # to check against "ends 15 Dec" than against a list. + ends_on: str | None = None + truncated: bool = False # more paydays exist than were drawn diff --git a/services.py b/services.py index 3d4ede0..f167e8c 100644 --- a/services.py +++ b/services.py @@ -101,18 +101,46 @@ def next_payday(contract: Contract) -> date | None: ) -def upcoming_paydays(contract: Contract, count: int) -> list[date]: - """The next `count` paydays, truncated by any period cap.""" - start = parse_start_date(contract) - last = contract.total_periods if contract.total_periods is not None else None - indices = range(contract.periods_done, contract.periods_done + count) +def paydays_from( + start: date, + frequency: Frequency, + first_index: int, + count: int, + total_periods: int | None = None, +) -> list[date]: + """`count` paydays starting at period `first_index`, truncated by any cap. + + Takes loose parameters rather than a Contract so the operator can preview + a schedule *before* the contract exists — which is the point at which a + mistyped start date or the wrong frequency is cheap to fix. + """ return [ - occurrence_on(start, contract.frequency, i) - for i in indices - if last is None or i < last + occurrence_on(start, frequency, i) + for i in range(first_index, first_index + count) + if total_periods is None or i < total_periods ] +def upcoming_paydays(contract: Contract, count: int) -> list[date]: + """The next `count` paydays for a live contract, from its position.""" + return paydays_from( + parse_start_date(contract), + contract.frequency, + contract.periods_done, + count, + contract.total_periods, + ) + + +def final_payday( + start: date, frequency: Frequency, total_periods: int | None +) -> date | None: + """When an open-ended contract would end — None, by definition.""" + if total_periods is None: + return None + return occurrence_on(start, frequency, total_periods - 1) + + def due_period_indices(contract: Contract, today: date) -> list[int]: """Every period index that is payable as of `today`, in order. @@ -516,3 +544,55 @@ def cancel(contract: Contract) -> Contract: raise LifecycleError(f"Contract is already {contract.status.value}.") contract.status = ContractStatus.cancelled return contract + + +# --------------------------------------------------------------------------- +# Off-cycle payout +# --------------------------------------------------------------------------- + + +async def pay_now(contract: Contract) -> Payout: + """Settle the contract's next period immediately, whatever the calendar + says. + + The operator's manual override: "run it now" when waiting five minutes + for the tick is not acceptable, and "pay it early" when a payday needs to + land before it is due. Both are the same operation — the next period + settles and is consumed — so there is one endpoint rather than two that + differ only in whether today happens to be the payday. + + It calls `pay_period` directly rather than going through `_settle`, + because `_settle`'s back-dated skip exists to stop a *new* contract + firing surprise back-pay. An operator explicitly asking to pay a period + is not a surprise. + """ + async with _lock_for(contract.id): + fresh = await crud.get_contract(contract.id) + if not fresh: + raise LifecycleError("Contract not found.") + contract = fresh + + if contract.status != ContractStatus.active: + raise LifecycleError( + f"Only an active contract can be paid (is {contract.status.value})." + ) + remaining = contract.periods_remaining + if remaining is not None and remaining <= 0: + raise LifecycleError("Contract has no periods left to pay.") + + index = contract.periods_done + payday = occurrence_on(parse_start_date(contract), contract.frequency, index) + today = datetime.now(timezone.utc).date() + + outcome = await pay_period(contract, index, payday) + if payday > today and outcome.status == PayoutStatus.paid: + outcome.detail = f"paid early on {today.isoformat()} (due {payday})" + + payout = await _record(contract, outcome) + if outcome.consumed: + contract.periods_done = index + 1 + _maybe_complete(contract) + else: + _maybe_pause_after_repeated_failure(contract, payout) + await crud.update_contract(contract) + return payout diff --git a/tests/test_payout.py b/tests/test_payout.py index 9ceaea1..defff71 100644 --- a/tests/test_payout.py +++ b/tests/test_payout.py @@ -13,7 +13,13 @@ from datetime import date import pytest from ..models import ContractStatus, PayoutStatus -from ..services import MAX_PERIOD_ATTEMPTS, PeriodOutcome, run_due_periods +from ..services import ( + MAX_PERIOD_ATTEMPTS, + LifecycleError, + PeriodOutcome, + pay_now, + run_due_periods, +) from .conftest import make_contract @@ -210,3 +216,44 @@ def test_a_failure_under_the_cap_leaves_the_contract_active(payroll_stub): 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)) diff --git a/tests/test_schedule.py b/tests/test_schedule.py index c795a2a..b37f4b9 100644 --- a/tests/test_schedule.py +++ b/tests/test_schedule.py @@ -14,8 +14,10 @@ from ..services import ( MAX_CATCH_UP_PERIODS, add_months, due_period_indices, + final_payday, next_payday, occurrence_on, + paydays_from, upcoming_paydays, ) from .conftest import make_contract @@ -147,3 +149,36 @@ def test_upcoming_paydays_are_truncated_by_the_period_cap(): def test_upcoming_paydays_are_unbounded_for_open_ended_contracts(): contract = make_contract(start_date="2026-01-15", total_periods=None) assert len(upcoming_paydays(contract, 24)) == 24 + + +# --- preview from loose terms ---------------------------------------------- + + +def test_paydays_from_draws_a_window_of_the_schedule(): + assert paydays_from(date(2026, 1, 31), Frequency.monthly, 0, 4) == [ + date(2026, 1, 31), + date(2026, 2, 28), + date(2026, 3, 31), + date(2026, 4, 30), + ] + + +def test_paydays_from_starts_at_the_given_index(): + assert paydays_from(date(2026, 1, 15), Frequency.weekly, 2, 2) == [ + date(2026, 1, 29), + date(2026, 2, 5), + ] + + +def test_paydays_from_stops_at_the_period_cap(): + drawn = paydays_from(date(2026, 1, 15), Frequency.monthly, 0, 10, total_periods=3) + assert len(drawn) == 3 + + +def test_final_payday_is_the_last_period_not_one_past_it(): + """12 monthly payments from 15 Jan end on 15 Dec, not 15 Jan next year.""" + assert final_payday(date(2026, 1, 15), Frequency.monthly, 12) == date(2026, 12, 15) + + +def test_open_ended_contracts_have_no_final_payday(): + assert final_payday(date(2026, 1, 15), Frequency.monthly, None) is None diff --git a/views_api.py b/views_api.py index 5d977f0..5d5db04 100644 --- a/views_api.py +++ b/views_api.py @@ -12,6 +12,7 @@ has for their own wallets. It is not instance-admin. (See the auth table in the workspace CLAUDE.md.) """ +from datetime import date, datetime from http import HTTPStatus from fastapi import APIRouter, Depends, HTTPException @@ -27,6 +28,8 @@ from .models import ( DirectoryUser, Payout, PayoutStatus, + SchedulePreview, + SchedulePreviewRequest, UpdateContract, ) @@ -243,3 +246,75 @@ async def api_list_payouts( return await crud.get_payouts( contract_id=contract_id, status=status, limit=min(limit, 1000) ) + + +# --------------------------------------------------------------------------- +# Schedule preview and off-cycle payout +# --------------------------------------------------------------------------- + + +def _preview( + start: date, frequency, total_periods: int | None, first_index: int, count: int +) -> SchedulePreview: + paydays = services.paydays_from(start, frequency, first_index, count, total_periods) + end = services.final_payday(start, frequency, total_periods) + return SchedulePreview( + paydays=[d.isoformat() for d in paydays], + total_periods=total_periods, + ends_on=end.isoformat() if end else None, + truncated=total_periods is None or first_index + count < total_periods, + ) + + +@payroll_api_router.post("/api/v1/schedule/preview") +async def api_preview_schedule(data: SchedulePreviewRequest) -> SchedulePreview: + """Draw a contract's calendar before it exists. + + The cheapest moment to notice a mistyped start date or the wrong + frequency is before anything is saved, so this takes loose terms rather + than a full contract — no employee or wallet needed. + """ + return _preview( + datetime.strptime(data.start_date, "%Y-%m-%d").date(), + data.frequency, + data.total_periods, + 0, + data.count, + ) + + +@payroll_api_router.get("/api/v1/contracts/{contract_id}/schedule") +async def api_contract_schedule(contract_id: str, count: int = 12) -> SchedulePreview: + """The paydays still ahead of a live contract, from its current position.""" + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + return _preview( + services.parse_start_date(contract), + contract.frequency, + contract.total_periods, + contract.periods_done, + max(1, min(count, 120)), + ) + + +@payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now") +async def api_pay_now(contract_id: str) -> Payout: + """Settle the next period immediately, whatever the calendar says. + + Covers both "run it now" (do not wait for the tick) and "pay it early", + which are the same operation — the next period settles and is consumed — + so they are one endpoint rather than two that differ only in whether + today happens to be the payday. + + Recorded in the ledger like any other payout, with the early-payment + noted in its detail. Returns the ledger row, including a failed one: + the caller wants to know *why* a manual payout did not land. + """ + contract = await crud.get_contract(contract_id) + if not contract: + raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.") + try: + return await services.pay_now(contract) + except services.LifecycleError as exc: + raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc From 4b6d647d23549040655c9099a987f0b610e97f4d Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:53:12 +0200 Subject: [PATCH 08/10] feat: CSV export and employee payslip view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audiences the super-user API did not serve. Accounting gets `GET /api/v1/payouts.csv`, filterable by contract, status and date range. The range bounds the *payday* rather than the row timestamp, so a period contains the paydays that belong to it even when one of them took three days of retries to settle — otherwise a late retry lands in the wrong month's export. Every exported field is neutralised against spreadsheet formula injection. `detail` carries exception text and the memo carries operator input, and a cell beginning `=`, `+`, `-` or `@` executes when the file is opened. Worth the eight lines: this file is written specifically to be opened in somebody else's spreadsheet. Employees get `/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts` on a **separate router** gated by a wallet invoice key rather than by super-user rights. Separate router so it cannot inherit — or accidentally shed — the wrong gate. Scoped to the wallet rather than the account because an invoice key names exactly one wallet, leaving no lookup that could widen the result to a sibling wallet the key does not cover. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- README.md | 7 ++++ __init__.py | 3 +- crud.py | 20 ++++++++++ docs/operations.md | 30 ++++++++++++++ export.py | 70 +++++++++++++++++++++++++++++++++ tests/test_export.py | 74 +++++++++++++++++++++++++++++++++++ views_api.py | 93 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 294 insertions(+), 3 deletions(-) create mode 100644 export.py create mode 100644 tests/test_export.py diff --git a/README.md b/README.md index f1adf29..6897391 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ workspace `CLAUDE.md`.) Add `payroll` to `LNBITS_ADMIN_EXTENSIONS` so the extension is hidden from ordinary users in the UI as well. +The exception is the employee-facing surface — `/api/v1/my/payouts`, +`/my/payouts.csv`, `/my/contracts` — which is gated on a wallet **invoice +key** instead. Whoever holds the read key for a wallet can see what payroll +paid into that wallet, and nothing else. It stays reachable regardless of +`LNBITS_ADMIN_EXTENSIONS`, which governs UI visibility rather than route +mounting. + ## Development ``` diff --git a/__init__.py b/__init__.py index a60bb29..6a49a2c 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ from fastapi import APIRouter from loguru import logger from .crud import db -from .views_api import payroll_api_router +from .views_api import payroll_api_router, payroll_employee_router payroll_static_files = [ { @@ -15,6 +15,7 @@ payroll_static_files = [ payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"]) payroll_ext.include_router(payroll_api_router) +payroll_ext.include_router(payroll_employee_router) scheduled_tasks: list[asyncio.Task] = [] diff --git a/crud.py b/crud.py index 2c76717..8029f26 100644 --- a/crud.py +++ b/crud.py @@ -101,17 +101,37 @@ async def create_payout(payout: Payout) -> Payout: async def get_payouts( contract_id: str | None = None, + employee_wallet: str | None = None, status: PayoutStatus | None = None, + since: str | None = None, + until: str | None = None, limit: int = 200, ) -> list[Payout]: + """Ledger rows, newest first. + + `since`/`until` bound the **payday**, not the row's creation time: an + accounting period is about which paydays fall in it, and a payday that + was retried for three days would otherwise land in the wrong month. + Both are inclusive `YYYY-MM-DD`, which compares correctly as a string + because that is the only format `payday` is ever written in. + """ where = [] values: dict = {} if contract_id: where.append("contract_id = :cid") values["cid"] = contract_id + if employee_wallet: + where.append("employee_wallet = :wid") + values["wid"] = employee_wallet if status: where.append("status = :status") values["status"] = status.value + if since: + where.append("payday >= :since") + values["since"] = since + if until: + where.append("payday <= :until") + values["until"] = until clause = f"WHERE {' AND '.join(where)}" if where else "" return await db.fetchall( f"SELECT * FROM payroll.payouts {clause} " diff --git a/docs/operations.md b/docs/operations.md index b0ad578..888c67b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -134,3 +134,33 @@ It is recorded in the ledger like any other payout, with the early payment noted in `detail`, and the ledger row is returned even when the payout failed — a manual payout that did not land is exactly when you want to know why. + +## Accounting export + +`GET /payroll/api/v1/payouts.csv` (super user) exports the ledger, with +optional `contract_id`, `status`, `since` and `until`. `since`/`until` bound +the **payday**, not the row timestamp, so an accounting period contains the +paydays that belong to it even when one took three days of retries to +settle. + +Every exported field is neutralised against spreadsheet formula injection — +a cell beginning `=`, `+`, `-` or `@` is prefixed with an apostrophe. +`detail` carries exception text and the memo carries operator input, and +neither is worth trusting to a colleague's Excel. + +## What an employee can see + +`/payroll/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts` are +gated on a **wallet invoice key**, not on admin rights: whoever holds the +read key for a wallet may see what payroll has paid into that wallet, and +nothing else. They live on a separate router from the super-user API so +they cannot inherit — or accidentally shed — the wrong gate. + +Scoped to the wallet rather than to the account on purpose: an invoice key +names exactly one wallet, so there is no lookup that could widen the result +to a sibling wallet the key does not cover. + +Note this stays reachable even with `payroll` in `LNBITS_ADMIN_EXTENSIONS`. +That flag governs who sees the extension in the UI; the API routes are +mounted instance-wide, and these three are gated by the wallet key they +require rather than by UI visibility. diff --git a/export.py b/export.py new file mode 100644 index 0000000..987a8d5 --- /dev/null +++ b/export.py @@ -0,0 +1,70 @@ +"""CSV export of the payout ledger. + +Payroll data leaves this extension for a spreadsheet, and a spreadsheet +treats a cell beginning with `=`, `+`, `-` or `@` as a formula. Every field +that reaches a cell is therefore neutralised on the way out — `detail` +carries exception text and the memo carries operator input, and neither is +worth trusting to a colleague's Excel. +""" + +import csv +import io + +from .models import Payout + +COLUMNS = [ + "payday", + "status", + "attempt", + "amount", + "currency", + "amount_sat", + "contract_id", + "period_index", + "employee_id", + "employee_wallet", + "source_wallet", + "payment_hash", + "detail", + "recorded_at", +] + +# Leading characters a spreadsheet reads as the start of a formula. +_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r") + + +def csv_safe(value) -> str: + """Render one value so a spreadsheet treats it as text, never a formula.""" + text = "" if value is None else str(value) + if text.startswith(_FORMULA_PREFIXES): + return "'" + text + return text + + +def payouts_to_csv(payouts: list[Payout]) -> str: + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(COLUMNS) + for p in payouts: + writer.writerow( + [ + csv_safe(v) + for v in ( + p.payday, + p.status.value, + p.attempt, + p.amount, + p.currency, + p.amount_sat, + p.contract_id, + p.period_index, + p.employee_id, + p.employee_wallet, + p.source_wallet, + p.payment_hash, + p.detail, + p.created_at.isoformat(), + ) + ] + ) + return buffer.getvalue() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..e6fe06b --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,74 @@ +"""CSV export. + +The interesting part is not the formatting — it is that payroll data leaves +here for a spreadsheet, and a spreadsheet executes a cell that starts with +`=`, `+`, `-` or `@`. +""" + +from datetime import datetime, timezone + +from ..export import COLUMNS, csv_safe, payouts_to_csv +from ..models import Payout, PayoutStatus + + +def make_payout(**overrides) -> Payout: + return Payout( + **{ + "id": "p1", + "contract_id": "c1", + "period_index": 0, + "payday": "2026-01-15", + "status": PayoutStatus.paid, + "amount_msat": 800_000, + "amount": 800, + "currency": "sat", + "employee_id": "employee-account", + "employee_wallet": "wallet-employee", + "source_wallet": "wallet-treasury", + "created_at": datetime(2026, 1, 15, 9, 0, tzinfo=timezone.utc), + **overrides, + } + ) + + +def test_header_and_one_row(): + out = payouts_to_csv([make_payout()]) + lines = out.strip().splitlines() + assert lines[0] == ",".join(COLUMNS) + assert lines[1].startswith("2026-01-15,paid,1,800.0,sat,800,c1,0,") + + +def test_amount_sat_comes_from_the_settled_msat_figure(): + row = payouts_to_csv( + [make_payout(amount=800, currency="EUR", amount_msat=1_234_000)] + ) + assert ",1234," in row # not re-derived from the EUR amount + + +def test_a_failed_row_exports_with_no_sat_amount(): + out = payouts_to_csv( + [make_payout(status=PayoutStatus.failed, amount_msat=None, detail="no funds")] + ) + assert "failed" in out and "no funds" in out + + +# --- spreadsheet formula injection ----------------------------------------- + + +def test_formula_prefixes_are_neutralised(): + for dangerous in ("=1+1", "+1", "-1", "@SUM(A1)", "\tx", "\rx"): + assert csv_safe(dangerous).startswith("'") + + +def test_ordinary_values_are_untouched(): + for benign in ("2026-01-15", "paid", "wallet-employee", "800.0", ""): + assert csv_safe(benign) == benign + + +def test_a_hostile_detail_cannot_smuggle_a_formula_into_a_cell(): + out = payouts_to_csv([make_payout(detail="=cmd|'/c calc'!A1")]) + assert "'=cmd" in out + + +def test_none_becomes_an_empty_cell(): + assert csv_safe(None) == "" diff --git a/views_api.py b/views_api.py index 5d5db04..7218974 100644 --- a/views_api.py +++ b/views_api.py @@ -15,13 +15,15 @@ the workspace CLAUDE.md.) from datetime import date, datetime from http import HTTPStatus -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Response from lnbits.core.crud import get_wallet -from lnbits.decorators import check_super_user +from lnbits.core.models import WalletTypeInfo +from lnbits.decorators import check_super_user, require_invoice_key from lnbits.utils.exchange_rates import allowed_currencies from . import crud, services from .accounts import list_directory_users, owns_wallet +from .export import payouts_to_csv from .models import ( Contract, CreateContract, @@ -318,3 +320,90 @@ async def api_pay_now(contract_id: str) -> Payout: return await services.pay_now(contract) except services.LifecycleError as exc: raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc + + +@payroll_api_router.get("/api/v1/payouts.csv") +async def api_export_payouts_csv( + contract_id: str | None = None, + status: PayoutStatus | None = None, + since: str | None = None, + until: str | None = None, +) -> Response: + """The ledger as CSV, for accounting. + + `since`/`until` bound the payday rather than the row's timestamp, so an + accounting period contains the paydays that belong to it even when one + of them took three days of retries to settle. + """ + payouts = await crud.get_payouts( + contract_id=contract_id, + status=status, + since=since, + until=until, + limit=100_000, + ) + filename = f"payroll-{since or 'all'}-{until or 'all'}.csv" + return Response( + content=payouts_to_csv(payouts), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +# --------------------------------------------------------------------------- +# Employee-facing surface +# --------------------------------------------------------------------------- + +# A separate router with a separate gate. Employees are not admins, so this +# is keyed on a wallet's invoice key: whoever holds the read key for a +# wallet may see what payroll has paid *into* that wallet, and nothing else. +# Keeping it off `payroll_api_router` is what stops it inheriting — or +# accidentally shedding — the super-user gate. +payroll_employee_router = APIRouter() + + +@payroll_employee_router.get("/api/v1/my/payouts") +async def api_my_payouts( + since: str | None = None, + until: str | None = None, + limit: int = 200, + key: WalletTypeInfo = Depends(require_invoice_key), +) -> list[Payout]: + """Payslips for the wallet whose key signed the request. + + Scoped to the wallet rather than to the account on purpose: the invoice + key names exactly one wallet, so there is no lookup that could widen the + result to a sibling wallet the key does not cover. + """ + return await crud.get_payouts( + employee_wallet=key.wallet.id, + since=since, + until=until, + limit=min(limit, 1000), + ) + + +@payroll_employee_router.get("/api/v1/my/payouts.csv") +async def api_my_payouts_csv( + since: str | None = None, + until: str | None = None, + key: WalletTypeInfo = Depends(require_invoice_key), +) -> Response: + """The same payslips as CSV, so an employee can file their own record.""" + payouts = await crud.get_payouts( + employee_wallet=key.wallet.id, since=since, until=until, limit=100_000 + ) + return Response( + content=payouts_to_csv(payouts), + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="payslips.csv"'}, + ) + + +@payroll_employee_router.get("/api/v1/my/contracts") +async def api_my_contracts( + key: WalletTypeInfo = Depends(require_invoice_key), +) -> list[Contract]: + """The payroll lines paying into this wallet — what is still owed, and + when the next one lands.""" + return await crud.get_contracts_for_wallet(key.wallet.id) From 23bc54f5581a1423cf98f681fd5358ec8b996b54 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 13:56:16 +0200 Subject: [PATCH 09/10] feat(ui): super-user payroll console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page the whole extension exists to be driven from: pick a user, pick which of their wallets to pay into, set amount, currency, frequency, start date and how many payments — plus the ledger, filters and a CSV button. Notes for review: - The employee-wallet picker only offers wallets belonging to the selected employee. The API rejects anything else, so this is about not presenting the mistake rather than about enforcement. - The dialog draws a live calendar from whatever is currently typed. A wrong start date or frequency is cheapest to catch before saving, which is what the preview endpoint was for. - "Next payday" comes from the schedule endpoint per row rather than being computed in JS. A payday this page derived for itself could disagree with the one the scheduler will actually use, and month-end is exactly where that would happen. - The destructive actions say what they do: resume warns that missed paydays are written off, delete says the schedule position goes with the row and points at cancel instead, pay-now says the period is consumed even if its payday has not arrived. - A refused pay-now surfaces the ledger row's reason with a longer toast — triggering a payout by hand is precisely when you want to know why it did not land. Quasar UMD rules honoured: no self-closing tags anywhere in the template, `${ }` delimiters so Jinja never sees a moustache, and `:style` bindings instead of a