Compare commits

..

No commits in common. "8a10c3caccde697b942ef017177c562434b73626" and "f253b51aa93eac441a8009bd2539b9812f6f56e7" have entirely different histories.

21 changed files with 11 additions and 3186 deletions

6
.gitignore vendored
View file

@ -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

View file

@ -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
```

View file

@ -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",
]

View file

@ -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)

75
crud.py
View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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);"
)

View file

@ -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

View file

@ -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

View file

@ -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 <style> blocks for typography, which LNbits themes
// override with !important. See workspace CLAUDE.md.
const API = '/payroll/api/v1'
const FREQUENCIES = [
{value: 'daily', label: 'Daily'},
{value: 'weekly', label: 'Weekly'},
{value: 'biweekly', label: 'Every two weeks'},
{value: 'monthly', label: 'Monthly'},
{value: 'quarterly', label: 'Quarterly'},
{value: 'yearly', label: 'Yearly'}
]
const STATUS_COLOUR = {
active: 'positive',
paused: 'warning',
completed: 'grey',
cancelled: 'grey-7',
paid: 'positive',
skipped: 'grey',
failed: 'negative'
}
function today() {
return new Date().toISOString().slice(0, 10)
}
function emptyContract() {
return {
employee_id: null,
employee_wallet: null,
source_wallet: null,
amount: null,
currency: 'sat',
frequency: 'monthly',
start_date: today(),
// The form models duration as a toggle plus a count, because
// "open-ended" and "12 payments" are the two things an operator
// actually says; the API takes total_periods = null for the former.
open_ended: false,
total_periods: 12,
label: '',
memo: '',
backfill: false
}
}
window.app = Vue.createApp({
el: '#vue',
mixins: [windowMixin],
delimiters: ['${', '}'],
data() {
return {
tab: 'contracts',
contracts: [],
contractsLoading: false,
payouts: [],
payoutsLoading: false,
users: [],
currencyOptions: ['sat'],
currencyFilter: ['sat'],
frequencyOptions: FREQUENCIES,
contractDialog: {show: false, data: emptyContract(), saving: false},
// Live calendar of whatever is currently typed into the dialog. The
// cheapest moment to notice a wrong start date or frequency is before
// anything is saved.
preview: null,
editDialog: {show: false, id: null, data: {}},
scheduleDialog: {show: false, contract: null, preview: null},
ledgerFilter: {contract_id: null, status: null, since: '', until: ''},
contractsColumns: [
{
name: 'employee',
label: 'Employee',
align: 'left',
field: row => row.label || row.employee_username || row.employee_id
},
{
name: 'amount',
label: 'Amount',
align: 'right',
field: row => `${row.amount} ${row.currency}`
},
{name: 'frequency', label: 'Every', align: 'left', field: 'frequency'},
{name: 'next', label: 'Next payday', align: 'left', field: 'id'},
{name: 'progress', label: 'Paid', align: 'center', field: 'id'},
{name: 'status', label: 'Status', align: 'center', field: 'status'},
{name: 'actions', label: '', align: 'right', field: 'id'}
],
payoutsColumns: [
{name: 'payday', label: 'Payday', align: 'left', field: 'payday'},
{name: 'status', label: 'Status', align: 'center', field: 'status'},
{
name: 'amount',
label: 'Amount',
align: 'right',
field: row =>
row.amount_msat === null
? '—'
: `${Math.round(row.amount_msat / 1000)} sat`
},
{
name: 'instruction',
label: 'Contract terms',
align: 'right',
field: row => `${row.amount} ${row.currency}`
},
{
name: 'contract',
label: 'Contract',
align: 'left',
field: 'contract_id'
},
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
]
}
},
computed: {
userOptions() {
return this.users.map(u => ({
label: u.username || u.email || u.id,
value: u.id,
caption: `${u.wallets.length} wallet(s)`
}))
},
// Wallets belonging to the employee currently selected in the dialog.
// The API rejects a wallet that is not theirs, so narrowing the picker
// is about not offering the mistake in the first place.
employeeWalletOptions() {
const user = this.users.find(
u => u.id === this.contractDialog.data.employee_id
)
if (!user) return []
return user.wallets.map(w => ({
label: `${w.name} — ${Math.round(w.balance_msat / 1000)} sat`,
value: w.id
}))
},
sourceWalletOptions() {
return (this.g.user.wallets || []).map(w => ({
label: w.name,
value: w.id
}))
},
contractOptions() {
return this.contracts.map(c => ({
label: `${c.label || c.employee_username || c.id} (${c.id})`,
value: c.id
}))
},
canSaveContract() {
const d = this.contractDialog.data
return !!(
d.employee_id &&
d.employee_wallet &&
d.source_wallet &&
d.amount > 0 &&
d.start_date
)
},
ledgerCsvHref() {
const f = this.ledgerFilter
const params = new URLSearchParams()
if (f.contract_id) params.append('contract_id', f.contract_id)
if (f.status) params.append('status', f.status)
if (f.since) params.append('since', f.since)
if (f.until) params.append('until', f.until)
return `${API}/payouts.csv?${params.toString()}`
}
},
watch: {
// Redraw the calendar whenever a term that shapes it changes.
'contractDialog.data.start_date': 'refreshPreview',
'contractDialog.data.frequency': 'refreshPreview',
'contractDialog.data.total_periods': 'refreshPreview',
'contractDialog.data.open_ended': 'refreshPreview'
},
methods: {
statusColour(status) {
return STATUS_COLOUR[status] || 'grey'
},
_err(err, fallback) {
if (err && err.response) return LNbits.utils.notifyApiError(err)
Quasar.Notify.create({type: 'negative', message: fallback})
},
// --- loading ----------------------------------------------------------
async getUsers() {
try {
const {data} = await LNbits.api.request('GET', `${API}/users`)
this.users = data
} catch (err) {
this._err(err, 'Could not load the account directory')
}
},
async getCurrencies() {
try {
const {data} = await LNbits.api.request('GET', `${API}/currencies`)
this.currencyOptions = data
this.currencyFilter = data
} catch (err) {
this._err(err, 'Could not load currencies')
}
},
filterCurrencies(needle, update) {
update(() => {
const term = (needle || '').toLowerCase()
this.currencyFilter = this.currencyOptions.filter(c =>
c.toLowerCase().includes(term)
)
})
},
async getContracts() {
this.contractsLoading = true
try {
const {data} = await LNbits.api.request('GET', `${API}/contracts`)
this.contracts = data
// Next payday is derived server-side; fetch it per row so the table
// never shows a date this page computed for itself.
await Promise.all(this.contracts.map(c => this.attachNextPayday(c)))
} catch (err) {
this._err(err, 'Could not load contracts')
} finally {
this.contractsLoading = false
}
},
async attachNextPayday(contract) {
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/contracts/${contract.id}/schedule?count=1`
)
contract.next_payday = data.paydays[0] || null
contract.ends_on = data.ends_on
} catch (err) {
contract.next_payday = null
}
},
async getPayouts() {
this.payoutsLoading = true
const f = this.ledgerFilter
const params = new URLSearchParams()
if (f.contract_id) params.append('contract_id', f.contract_id)
if (f.status) params.append('status', f.status)
if (f.since) params.append('since', f.since)
if (f.until) params.append('until', f.until)
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/payouts?${params.toString()}`
)
this.payouts = data
} catch (err) {
this._err(err, 'Could not load the payout ledger')
} finally {
this.payoutsLoading = false
}
},
// --- create / edit ----------------------------------------------------
openContractDialog() {
this.contractDialog.data = emptyContract()
this.preview = null
this.contractDialog.show = true
this.refreshPreview()
},
async refreshPreview() {
const d = this.contractDialog.data
if (!d.start_date || !/^\d{4}-\d{2}-\d{2}$/.test(d.start_date)) {
this.preview = null
return
}
try {
const {data} = await LNbits.api.request(
'POST',
`${API}/schedule/preview`,
null,
{
start_date: d.start_date,
frequency: d.frequency,
total_periods: d.open_ended ? null : Number(d.total_periods),
count: 6
}
)
this.preview = data
} catch (err) {
this.preview = null
}
},
async saveContract() {
const d = this.contractDialog.data
this.contractDialog.saving = true
try {
await LNbits.api.request('POST', `${API}/contracts`, null, {
employee_id: d.employee_id,
employee_wallet: d.employee_wallet,
source_wallet: d.source_wallet,
amount: Number(d.amount),
currency: d.currency,
frequency: d.frequency,
start_date: d.start_date,
total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label,
memo: d.memo,
backfill: d.backfill
})
this.contractDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
this.getContracts()
} catch (err) {
this._err(err, 'Could not create the payroll line')
} finally {
this.contractDialog.saving = false
}
},
openEditDialog(contract) {
this.editDialog = {
show: true,
id: contract.id,
data: {
amount: contract.amount,
currency: contract.currency,
frequency: contract.frequency,
open_ended: contract.total_periods === null,
total_periods: contract.total_periods,
label: contract.label,
memo: contract.memo
}
}
},
async saveEdit() {
const d = this.editDialog.data
try {
await LNbits.api.request(
'PUT',
`${API}/contracts/${this.editDialog.id}`,
null,
{
amount: Number(d.amount),
currency: d.currency,
frequency: d.frequency,
total_periods: d.open_ended ? null : Number(d.total_periods),
label: d.label,
memo: d.memo
}
)
this.editDialog.show = false
Quasar.Notify.create({type: 'positive', message: 'Terms updated'})
this.getContracts()
} catch (err) {
this._err(err, 'Could not update the contract')
}
},
// --- lifecycle --------------------------------------------------------
async transition(contract, action, params = '') {
try {
await LNbits.api.request(
'POST',
`${API}/contracts/${contract.id}/${action}${params}`
)
this.getContracts()
} catch (err) {
this._err(err, `Could not ${action} the contract`)
}
},
resumeContract(contract) {
LNbits.utils
.confirmDialog(
'Resume this contract? The paydays missed while it was paused ' +
'are written off — they are not paid retroactively.'
)
.onOk(() => this.transition(contract, 'resume'))
},
cancelContract(contract) {
LNbits.utils
.confirmDialog(
'Cancel this contract for good? It stops paying but keeps its ' +
'history. This cannot be undone.'
)
.onOk(() => this.transition(contract, 'cancel'))
},
payNow(contract) {
LNbits.utils
.confirmDialog(
`Pay the next period of "${contract.label || contract.id}" now? ` +
'It is consumed even if its payday has not arrived yet.'
)
.onOk(async () => {
try {
const {data} = await LNbits.api.request(
'POST',
`${API}/contracts/${contract.id}/pay-now`
)
// A refused payout still returns a ledger row — surfacing its
// reason is the whole point of triggering one by hand.
Quasar.Notify.create({
type: data.status === 'paid' ? 'positive' : 'negative',
message:
data.status === 'paid'
? `Paid ${Math.round(data.amount_msat / 1000)} sat`
: `Payout ${data.status}: ${data.detail}`,
timeout: data.status === 'paid' ? 3000 : 8000
})
this.getContracts()
this.getPayouts()
} catch (err) {
this._err(err, 'Could not pay now')
}
})
},
deleteContract(contract) {
LNbits.utils
.confirmDialog(
'Delete this contract row outright? Its schedule position goes ' +
'with it. To stop a running payroll, cancel it instead — that ' +
'keeps the record.'
)
.onOk(async () => {
try {
await LNbits.api.request('DELETE', `${API}/contracts/${contract.id}`)
this.getContracts()
} catch (err) {
this._err(err, 'Could not delete the contract')
}
})
},
async showSchedule(contract) {
this.scheduleDialog = {show: true, contract, preview: null}
try {
const {data} = await LNbits.api.request(
'GET',
`${API}/contracts/${contract.id}/schedule?count=24`
)
this.scheduleDialog.preview = data
} catch (err) {
this._err(err, 'Could not load the schedule')
}
},
showLedgerFor(contract) {
this.ledgerFilter.contract_id = contract.id
this.tab = 'ledger'
this.getPayouts()
}
},
created() {
this.getUsers()
this.getCurrencies()
this.getContracts()
this.getPayouts()
}
})

View file

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

View file

@ -1,373 +0,0 @@
{% extends "base.html" %}
{% from "macros.jinja" import window_vars with context %}
{% block scripts %}
{{ window_vars(user) }}
<script src="{{ static_url_for('payroll/static', path='js/index.js') }}"></script>
{% endblock %}
{% block page %}
<div class="row q-col-gutter-md">
<div class="col-12">
<div class="row items-center q-mb-md">
<div class="col">
<h4 class="q-my-none">Payroll</h4>
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
Recurring payouts from a wallet you control into an employee's wallet.
</p>
</div>
<div class="col-auto">
<q-btn unelevated color="primary" icon="add" label="New payroll line"
@click="openContractDialog"></q-btn>
</div>
</div>
<q-card>
<q-tabs v-model="tab" align="left"
active-color="primary" indicator-color="primary" class="text-grey">
<q-tab name="contracts" icon="event_repeat" label="Contracts"></q-tab>
<q-tab name="ledger" icon="receipt_long" label="Payout ledger"></q-tab>
</q-tabs>
<q-separator></q-separator>
<q-tab-panels v-model="tab" animated>
<!-- ------------------------------------------------ contracts -->
<q-tab-panel name="contracts">
<q-table flat dense :rows="contracts" :columns="contractsColumns"
row-key="id" :loading="contractsLoading"
:pagination="{rowsPerPage: 25}">
<template v-slot:body-cell-next="props">
<q-td :props="props">
<span v-if="props.row.next_payday">
${ props.row.next_payday }
</span>
<span v-else :style="{opacity: 0.5}">—</span>
</q-td>
</template>
<template v-slot:body-cell-progress="props">
<q-td :props="props">
${ props.row.periods_done }<span :style="{opacity: 0.5}">
/ ${ props.row.total_periods === null ? '∞' : props.row.total_periods }
</span>
</q-td>
</template>
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense square :color="statusColour(props.row.status)"
text-color="white" :label="props.row.status"></q-chip>
</q-td>
</template>
<template v-slot:body-cell-actions="props">
<q-td :props="props" class="q-gutter-xs">
<q-btn flat dense round icon="event" size="sm"
@click="showSchedule(props.row)">
<q-tooltip>Upcoming paydays</q-tooltip>
</q-btn>
<q-btn flat dense round icon="receipt_long" size="sm"
@click="showLedgerFor(props.row)">
<q-tooltip>Payout history</q-tooltip>
</q-btn>
<q-btn flat dense round icon="edit" size="sm"
@click="openEditDialog(props.row)">
<q-tooltip>Edit terms</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active'"
flat dense round icon="bolt" size="sm" color="primary"
@click="payNow(props.row)">
<q-tooltip>Pay the next period now</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active'"
flat dense round icon="pause" size="sm"
@click="transition(props.row, 'pause')">
<q-tooltip>Pause</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'paused'"
flat dense round icon="play_arrow" size="sm" color="positive"
@click="resumeContract(props.row)">
<q-tooltip>Resume</q-tooltip>
</q-btn>
<q-btn v-if="props.row.status === 'active' || props.row.status === 'paused'"
flat dense round icon="block" size="sm" color="warning"
@click="cancelContract(props.row)">
<q-tooltip>Cancel for good</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" size="sm" color="negative"
@click="deleteContract(props.row)">
<q-tooltip>Delete the row and its schedule position</q-tooltip>
</q-btn>
</q-td>
</template>
<template v-slot:no-data>
<div class="full-width row justify-center q-pa-lg"
:style="{opacity: 0.6}">
No payroll lines yet.
</div>
</template>
</q-table>
</q-tab-panel>
<!-- --------------------------------------------------- ledger -->
<q-tab-panel name="ledger">
<div class="row q-col-gutter-sm q-mb-md items-end">
<div class="col-12 col-md-4">
<q-select outlined dense clearable v-model="ledgerFilter.contract_id"
:options="contractOptions" emit-value map-options
label="Contract" @update:model-value="getPayouts"></q-select>
</div>
<div class="col-6 col-md-2">
<q-select outlined dense clearable v-model="ledgerFilter.status"
:options="['paid', 'failed', 'skipped']"
label="Status" @update:model-value="getPayouts"></q-select>
</div>
<div class="col-6 col-md-2">
<q-input outlined dense v-model="ledgerFilter.since"
mask="####-##-##" label="Payday from" @blur="getPayouts"></q-input>
</div>
<div class="col-6 col-md-2">
<q-input outlined dense v-model="ledgerFilter.until"
mask="####-##-##" label="Payday to" @blur="getPayouts"></q-input>
</div>
<div class="col-6 col-md-2">
<q-btn outline color="primary" icon="download" label="CSV"
type="a" :href="ledgerCsvHref"></q-btn>
</div>
</div>
<q-table flat dense :rows="payouts" :columns="payoutsColumns"
row-key="id" :loading="payoutsLoading"
:pagination="{rowsPerPage: 25}">
<template v-slot:body-cell-status="props">
<q-td :props="props">
<q-chip dense square :color="statusColour(props.row.status)"
text-color="white" :label="props.row.status"></q-chip>
</q-td>
</template>
<template v-slot:body-cell-detail="props">
<q-td :props="props">
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
${ props.row.detail }
</span>
</q-td>
</template>
<template v-slot:no-data>
<div class="full-width row justify-center q-pa-lg"
:style="{opacity: 0.6}">
Nothing paid yet. Failures and skips land here too.
</div>
</template>
</q-table>
</q-tab-panel>
</q-tab-panels>
</q-card>
</div>
</div>
<!-- ================================================ new contract -->
<q-dialog v-model="contractDialog.show">
<q-card style="width: 640px; max-width: 95vw">
<q-card-section>
<div class="text-h6">New payroll line</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<q-select outlined dense v-model="contractDialog.data.employee_id"
:options="userOptions" emit-value map-options
label="Employee *"
@update:model-value="contractDialog.data.employee_wallet = null">
</q-select>
<q-select outlined dense v-model="contractDialog.data.employee_wallet"
:options="employeeWalletOptions" emit-value map-options
:disable="!contractDialog.data.employee_id"
label="Pay into (their wallet) *"
hint="Only wallets belonging to the selected employee">
</q-select>
<q-select outlined dense v-model="contractDialog.data.source_wallet"
:options="sourceWalletOptions" emit-value map-options
label="Pay from (your wallet) *"></q-select>
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-input outlined dense type="number" step="any"
v-model.number="contractDialog.data.amount" label="Amount *"></q-input>
</div>
<div class="col-5">
<q-select outlined dense use-input input-debounce="0"
v-model="contractDialog.data.currency"
:options="currencyFilter" @filter="filterCurrencies"
label="Currency"
hint="Fiat is priced in sats at each payout"></q-select>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-select outlined dense v-model="contractDialog.data.frequency"
:options="frequencyOptions" emit-value map-options
label="Frequency"></q-select>
</div>
<div class="col-6">
<q-input outlined dense v-model="contractDialog.data.start_date"
mask="####-##-##" label="First payday *"
hint="Anchors every later payday">
<template v-slot:append>
<q-icon name="event" class="cursor-pointer">
<q-popup-proxy cover transition-show="scale">
<q-date v-model="contractDialog.data.start_date"
mask="YYYY-MM-DD"></q-date>
</q-popup-proxy>
</q-icon>
</template>
</q-input>
</div>
</div>
<div class="row items-center q-col-gutter-sm">
<div class="col-auto">
<q-toggle v-model="contractDialog.data.open_ended"
label="Open-ended"></q-toggle>
</div>
<div class="col">
<q-input outlined dense type="number"
v-model.number="contractDialog.data.total_periods"
:disable="contractDialog.data.open_ended"
label="Number of payments"></q-input>
</div>
</div>
<q-input outlined dense v-model="contractDialog.data.label"
label="Label" hint="Shown in this table, e.g. 'Alice — dev retainer'"></q-input>
<q-input outlined dense v-model="contractDialog.data.memo"
label="Payment memo" hint="What the employee sees on the payment"></q-input>
<q-toggle v-model="contractDialog.data.backfill"
label="Pay paydays that already passed">
<q-tooltip>
Off by default: a back-dated start normally means "we pay on the
15th", not "send five months of back-pay now".
</q-tooltip>
</q-toggle>
<q-separator></q-separator>
<div v-if="preview">
<div class="text-subtitle2 q-mb-xs">Next paydays</div>
<div class="row q-gutter-xs">
<q-chip v-for="d in preview.paydays" :key="d" dense square outline
:label="d"></q-chip>
<q-chip v-if="preview.truncated" dense square outline label="…"></q-chip>
</div>
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
<span v-if="preview.ends_on">
${ preview.total_periods } payment(s), ending ${ preview.ends_on }.
</span>
<span v-else>Open-ended — pays until paused or cancelled.</span>
</div>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Create" :loading="contractDialog.saving"
:disable="!canSaveContract" @click="saveContract"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- ======================================================= edit -->
<q-dialog v-model="editDialog.show">
<q-card style="width: 480px; max-width: 95vw">
<q-card-section>
<div class="text-h6">Edit terms</div>
<div class="text-caption" :style="{opacity: 0.7}">
The employee, the source wallet and the start date are fixed. Changing
them would rewrite what past payouts were for. Cancel and re-create
instead.
</div>
</q-card-section>
<q-card-section class="q-gutter-md">
<div class="row q-col-gutter-sm">
<div class="col-7">
<q-input outlined dense type="number" step="any"
v-model.number="editDialog.data.amount" label="Amount"></q-input>
</div>
<div class="col-5">
<q-select outlined dense use-input input-debounce="0"
v-model="editDialog.data.currency"
:options="currencyFilter" @filter="filterCurrencies"
label="Currency"></q-select>
</div>
</div>
<q-select outlined dense v-model="editDialog.data.frequency"
:options="frequencyOptions" emit-value map-options
label="Frequency"></q-select>
<div class="row items-center q-col-gutter-sm">
<div class="col-auto">
<q-toggle v-model="editDialog.data.open_ended" label="Open-ended"></q-toggle>
</div>
<div class="col">
<q-input outlined dense type="number"
v-model.number="editDialog.data.total_periods"
:disable="editDialog.data.open_ended"
label="Number of payments"></q-input>
</div>
</div>
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
<q-input outlined dense v-model="editDialog.data.memo"
label="Payment memo"></q-input>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Cancel" v-close-popup></q-btn>
<q-btn color="primary" label="Save" @click="saveEdit"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<!-- =================================================== schedule -->
<q-dialog v-model="scheduleDialog.show">
<q-card style="width: 420px; max-width: 95vw">
<q-card-section>
<div class="text-h6">Upcoming paydays</div>
<div v-if="scheduleDialog.contract" class="text-caption"
:style="{opacity: 0.7}">
${ scheduleDialog.contract.label || scheduleDialog.contract.employee_username }
— ${ scheduleDialog.contract.amount } ${ scheduleDialog.contract.currency }
${ scheduleDialog.contract.frequency }
</div>
</q-card-section>
<q-card-section>
<div v-if="scheduleDialog.preview">
<div class="row q-gutter-xs">
<q-chip v-for="d in scheduleDialog.preview.paydays" :key="d"
dense square outline :label="d"></q-chip>
</div>
<div v-if="!scheduleDialog.preview.paydays.length" :style="{opacity: 0.6}">
No paydays left.
</div>
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
<span v-if="scheduleDialog.preview.ends_on">
Ends ${ scheduleDialog.preview.ends_on }.
</span>
<span v-else>Open-ended.</span>
</div>
</div>
<q-spinner v-else color="primary"></q-spinner>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Close" v-close-popup></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
{% endblock %}

View file

View file

@ -1,43 +0,0 @@
"""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
),
)

View file

@ -1,74 +0,0 @@
"""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) == ""

View file

@ -1,107 +0,0 @@
"""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))

View file

@ -1,259 +0,0 @@
"""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, PayoutStatus
from ..services import (
MAX_PERIOD_ATTEMPTS,
LifecycleError,
PeriodOutcome,
pay_now,
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 = 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()
async def fake_get_contract(_contract_id):
return stub.contract
async def fake_update_contract(contract):
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 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.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.
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 [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
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 [p.status for p in outcomes] == [PayoutStatus.paid] * 3
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 = PayoutStatus.failed
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20)))
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
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 == []
# --- 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
# --- 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))

View file

@ -1,184 +0,0 @@
"""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,
final_payday,
next_payday,
occurrence_on,
paydays_from,
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
# --- 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

View file

@ -1,35 +0,0 @@
"""Frontend route — serves the operator console.
Gated the same way the API is: super user only. `check_user_exists` is used
rather than `check_super_user` because the template needs the full `User`
(its wallets populate the source-wallet picker) and `check_super_user`
resolves to an `Account`; the privilege check is then made explicitly off
`user.super_user`. Rejecting here is a UX nicety — the API behind the page
is gated independently and does not trust this route.
"""
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from lnbits.helpers import template_renderer
payroll_generic_router = APIRouter()
def payroll_renderer():
return template_renderer(["payroll/templates"])
@payroll_generic_router.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
if not user.super_user:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Payroll is restricted to the instance super user.",
)
return payroll_renderer().TemplateResponse(
"payroll/index.html", {"request": request, "user": user.json()}
)

View file

@ -1,409 +0,0 @@
"""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 datetime import date, datetime
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException, Response
from lnbits.core.crud import get_wallet
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,
DirectoryUser,
Payout,
PayoutStatus,
SchedulePreview,
SchedulePreviewRequest,
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)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# 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)
)
# ---------------------------------------------------------------------------
# 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
@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)