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 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 new file mode 100644 index 0000000..173647f --- /dev/null +++ b/__init__.py @@ -0,0 +1,49 @@ +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_employee_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) +payroll_ext.include_router(payroll_employee_router) + +scheduled_tasks: list[asyncio.Task] = [] + + +def payroll_stop(): + for task in scheduled_tasks: + try: + task.cancel() + except Exception as ex: # shutdown must not raise + 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/accounts.py b/accounts.py new file mode 100644 index 0000000..458aeaa --- /dev/null +++ b/accounts.py @@ -0,0 +1,67 @@ +"""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/crud.py b/crud.py index 71c98a6..80af91f 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,76 @@ 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, + 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} " + "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. + """ + # No model: fetchone's TModel is unbound for a bare aggregate, so the + # row comes back as a plain mapping and needs its own annotation. + row: dict | None = 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 new file mode 100644 index 0000000..888c67b --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,166 @@ +# 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 +``` + +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 +*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. + +## 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. + +## 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/migrations.py b/migrations.py index 547b587..282e1fc 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,17 +36,55 @@ 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);" ) + + +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 782eaf9..15ccaac 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 @@ -191,3 +191,94 @@ 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 + + +# --------------------------------------------------------------------------- +# 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 new file mode 100644 index 0000000..f167e8c --- /dev/null +++ b/services.py @@ -0,0 +1,598 @@ +"""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 lnbits.helpers import urlsafe_short_hash +from loguru import logger + +from . import crud +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. +_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 + +# 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) +# --------------------------------------------------------------------------- + + +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 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, 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. + + 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: PayoutStatus + 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 (PayoutStatus.paid, PayoutStatus.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, + ) + # 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=PayoutStatus.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=PayoutStatus.failed, + detail="source wallet not found", + amount_msat=amount_msat, + ) + if source.balance_msat < amount_msat: + return PeriodOutcome( + index=index, + payday=payday, + status=PayoutStatus.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: # a failed payment is an expected outcome + return PeriodOutcome( + index=index, + payday=payday, + status=PayoutStatus.failed, + detail=f"payment failed: {exc}", + amount_msat=amount_msat, + payment_hash=invoice.payment_hash, + ) + + return PeriodOutcome( + index=index, + payday=payday, + status=PayoutStatus.paid, + amount_msat=amount_msat, + payment_hash=invoice.payment_hash, + ) + + +async def run_due_periods( + contract: Contract, today: date | None = None +) -> 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 + 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 + + 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) + 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 payouts: + _maybe_complete(contract) + await crud.update_contract(contract) + + 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: + """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=PayoutStatus.skipped, + detail="payday predates the contract and backfill is off", + ) + + outcome = await pay_period(contract, index, payday) + if outcome.status == PayoutStatus.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.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 + ): + 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: # one bad row must not stop the rest of payroll + 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 + + +# --------------------------------------------------------------------------- +# 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/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000..abf6f95 --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,491 @@ +// Payroll operator console — LNbits page shell (Vue 3 + Quasar 2 UMD, no +// build step). Gotchas honored: no self-closing tags in the template, +// `${ }` interpolation so Jinja never sees a moustache, and `:style` +// bindings rather than