diff --git a/.gitignore b/.gitignore index 4f0cdd0..d990a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,3 @@ 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 6897391..f1adf29 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,6 @@ 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 deleted file mode 100644 index 173647f..0000000 --- a/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 458aeaa..0000000 --- a/accounts.py +++ /dev/null @@ -1,67 +0,0 @@ -"""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 80af91f..71c98a6 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, Payout, PayoutStatus +from .models import Contract, ContractStatus, CreateContract db = Database("ext_payroll") @@ -87,76 +87,3 @@ 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 deleted file mode 100644 index 888c67b..0000000 --- a/docs/operations.md +++ /dev/null @@ -1,166 +0,0 @@ -# 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 deleted file mode 100644 index 987a8d5..0000000 --- a/export.py +++ /dev/null @@ -1,70 +0,0 @@ -"""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 282e1fc..547b587 100644 --- a/migrations.py +++ b/migrations.py @@ -16,7 +16,8 @@ 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, @@ -36,55 +37,17 @@ 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 15ccaac..782eaf9 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,94 +191,3 @@ 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 deleted file mode 100644 index f167e8c..0000000 --- a/services.py +++ /dev/null @@ -1,598 +0,0 @@ -"""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 deleted file mode 100644 index abf6f95..0000000 --- a/static/js/index.js +++ /dev/null @@ -1,491 +0,0 @@ -// 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