Compare commits
No commits in common. "8a10c3caccde697b942ef017177c562434b73626" and "f253b51aa93eac441a8009bd2539b9812f6f56e7" have entirely different histories.
8a10c3cacc
...
f253b51aa9
21 changed files with 11 additions and 3186 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
49
__init__.py
49
__init__.py
|
|
@ -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",
|
||||
]
|
||||
67
accounts.py
67
accounts.py
|
|
@ -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
75
crud.py
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
|||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import Contract, ContractStatus, CreateContract, Payout, PayoutStatus
|
||||
from .models import Contract, ContractStatus, CreateContract
|
||||
|
||||
db = Database("ext_payroll")
|
||||
|
||||
|
|
@ -87,76 +87,3 @@ async def delete_contract(contract_id: str) -> None:
|
|||
await db.execute(
|
||||
"DELETE FROM payroll.contracts WHERE id = :id", {"id": contract_id}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payout ledger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_payout(payout: Payout) -> Payout:
|
||||
await db.insert("payroll.payouts", payout)
|
||||
return payout
|
||||
|
||||
|
||||
async def get_payouts(
|
||||
contract_id: str | None = None,
|
||||
employee_wallet: str | None = None,
|
||||
status: PayoutStatus | None = None,
|
||||
since: str | None = None,
|
||||
until: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[Payout]:
|
||||
"""Ledger rows, newest first.
|
||||
|
||||
`since`/`until` bound the **payday**, not the row's creation time: an
|
||||
accounting period is about which paydays fall in it, and a payday that
|
||||
was retried for three days would otherwise land in the wrong month.
|
||||
Both are inclusive `YYYY-MM-DD`, which compares correctly as a string
|
||||
because that is the only format `payday` is ever written in.
|
||||
"""
|
||||
where = []
|
||||
values: dict = {}
|
||||
if contract_id:
|
||||
where.append("contract_id = :cid")
|
||||
values["cid"] = contract_id
|
||||
if employee_wallet:
|
||||
where.append("employee_wallet = :wid")
|
||||
values["wid"] = employee_wallet
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
values["status"] = status.value
|
||||
if since:
|
||||
where.append("payday >= :since")
|
||||
values["since"] = since
|
||||
if until:
|
||||
where.append("payday <= :until")
|
||||
values["until"] = until
|
||||
clause = f"WHERE {' AND '.join(where)}" if where else ""
|
||||
return await db.fetchall(
|
||||
f"SELECT * FROM payroll.payouts {clause} "
|
||||
"ORDER BY created_at DESC LIMIT :limit",
|
||||
{**values, "limit": limit},
|
||||
Payout,
|
||||
)
|
||||
|
||||
|
||||
async def count_period_failures(contract_id: str, period_index: int) -> int:
|
||||
"""How many times this exact period has already failed.
|
||||
|
||||
Drives the bounded-retry cap. Counted from the ledger rather than from a
|
||||
counter on the contract so the number survives a restart and stays
|
||||
auditable — the rows that produced it are right there.
|
||||
"""
|
||||
# No model: fetchone's TModel is unbound for a bare aggregate, so the
|
||||
# row comes back as a plain mapping and needs its own annotation.
|
||||
row: dict | None = await db.fetchone(
|
||||
"SELECT COUNT(*) AS n FROM payroll.payouts "
|
||||
"WHERE contract_id = :cid AND period_index = :idx AND status = :status",
|
||||
{
|
||||
"cid": contract_id,
|
||||
"idx": period_index,
|
||||
"status": PayoutStatus.failed.value,
|
||||
},
|
||||
)
|
||||
return int(row["n"]) if row else 0
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
70
export.py
70
export.py
|
|
@ -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()
|
||||
|
|
@ -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);"
|
||||
)
|
||||
|
|
|
|||
97
models.py
97
models.py
|
|
@ -56,8 +56,8 @@ class Frequency(str, Enum):
|
|||
|
||||
|
||||
class ContractStatus(str, Enum):
|
||||
active = "active" # eligible for payout on every scheduler tick
|
||||
paused = "paused" # keeps its schedule position, pays nothing
|
||||
active = "active" # eligible for payout on every scheduler tick
|
||||
paused = "paused" # keeps its schedule position, pays nothing
|
||||
completed = "completed" # ran out of periods; terminal
|
||||
cancelled = "cancelled" # stopped by the operator; terminal
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ class CreateContract(BaseModel):
|
|||
total_periods: int | None = None
|
||||
|
||||
label: str = "" # display name for the line, e.g. "Alice — dev retainer"
|
||||
memo: str = "" # payment memo the employee sees; falls back to `label`
|
||||
memo: str = "" # payment memo the employee sees; falls back to `label`
|
||||
|
||||
# Periods whose payday already passed before the contract existed. Off by
|
||||
# default: creating a contract with a back-dated start is normally an
|
||||
|
|
@ -191,94 +191,3 @@ class DirectoryUser(BaseModel):
|
|||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.username or self.email or self.id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payout ledger
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PayoutStatus(str, Enum):
|
||||
paid = "paid" # money moved
|
||||
skipped = "skipped" # period deliberately not paid (back-dated, paused-over)
|
||||
failed = "failed" # attempted and could not settle; will be retried
|
||||
|
||||
|
||||
class Payout(BaseModel):
|
||||
"""One attempt at one period — the audit trail.
|
||||
|
||||
Deliberately self-describing rather than a thin join key. A ledger row
|
||||
has to still make sense after its contract is edited or deleted, so the
|
||||
terms in force at the time (amount, currency, both wallets) are copied
|
||||
onto it. This is the one place in the extension where duplicating a
|
||||
contract field is right: the contract holds what is true *now*, a payout
|
||||
holds what was true *then*.
|
||||
|
||||
`amount_msat` is the canonical settled figure, taken from the invoice
|
||||
LNbits priced. It is null on a skip, and on a failure that never got as
|
||||
far as raising an invoice.
|
||||
"""
|
||||
|
||||
id: str
|
||||
contract_id: str
|
||||
period_index: int
|
||||
payday: str # YYYY-MM-DD
|
||||
status: PayoutStatus
|
||||
# 1-based, counted per (contract, period). Only failures accumulate
|
||||
# attempts — a paid or skipped period is never retried.
|
||||
attempt: int = 1
|
||||
|
||||
amount_msat: int | None = None
|
||||
amount: float = 0 # the instruction in force at the time
|
||||
currency: str = "sat"
|
||||
|
||||
employee_id: str = ""
|
||||
employee_wallet: str = ""
|
||||
source_wallet: str = ""
|
||||
|
||||
payment_hash: str | None = None
|
||||
detail: str = ""
|
||||
|
||||
created_at: datetime = Field(default_factory=_now)
|
||||
|
||||
@property
|
||||
def amount_sat(self) -> int | None:
|
||||
return None if self.amount_msat is None else self.amount_msat // 1000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schedule preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SchedulePreviewRequest(BaseModel):
|
||||
"""Enough of a contract to draw its calendar, with none of its identity.
|
||||
|
||||
Deliberately not a `CreateContract`: previewing must work before an
|
||||
employee or a wallet has been chosen, since the schedule is usually the
|
||||
thing the operator wants to sanity-check first.
|
||||
"""
|
||||
|
||||
start_date: str # YYYY-MM-DD
|
||||
frequency: Frequency = Frequency.monthly
|
||||
total_periods: int | None = None
|
||||
count: int = 12 # how many paydays to draw
|
||||
|
||||
@validator("start_date")
|
||||
def _valid_date(cls, v: str) -> str:
|
||||
datetime.strptime(v, "%Y-%m-%d")
|
||||
return v
|
||||
|
||||
@validator("count")
|
||||
def _sane_count(cls, v: int) -> int:
|
||||
return max(1, min(v, 120))
|
||||
|
||||
|
||||
class SchedulePreview(BaseModel):
|
||||
paydays: list[str] # YYYY-MM-DD, in order
|
||||
total_periods: int | None = None
|
||||
# The contract's last payday, or None for an open-ended one. Worth
|
||||
# surfacing on its own: "12 monthly payments from 15 Jan" is much easier
|
||||
# to check against "ends 15 Dec" than against a list.
|
||||
ends_on: str | None = None
|
||||
truncated: bool = False # more paydays exist than were drawn
|
||||
|
|
|
|||
598
services.py
598
services.py
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
36
tasks.py
36
tasks.py
|
|
@ -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)
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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
|
||||
),
|
||||
)
|
||||
|
|
@ -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) == ""
|
||||
|
|
@ -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))
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
35
views.py
35
views.py
|
|
@ -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()}
|
||||
)
|
||||
409
views_api.py
409
views_api.py
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue