Compare commits
10 commits
f253b51aa9
...
8a10c3cacc
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a10c3cacc | |||
| 23bc54f558 | |||
| 4b6d647d23 | |||
| ee10b2e04d | |||
| 8b054d27ea | |||
| 95bfa86f79 | |||
| 99f2131474 | |||
| 55f3dfac91 | |||
| 889751a41f | |||
| dc29173ada |
21 changed files with 3186 additions and 11 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -8,3 +8,9 @@ node_modules/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.mypy_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,6 +25,13 @@ workspace `CLAUDE.md`.)
|
||||||
Add `payroll` to `LNBITS_ADMIN_EXTENSIONS` so the extension is hidden from
|
Add `payroll` to `LNBITS_ADMIN_EXTENSIONS` so the extension is hidden from
|
||||||
ordinary users in the UI as well.
|
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
|
## Development
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
49
__init__.py
Normal file
49
__init__.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from .crud import db
|
||||||
|
from .views import payroll_generic_router
|
||||||
|
from .views_api import payroll_api_router, payroll_employee_router
|
||||||
|
|
||||||
|
payroll_static_files = [
|
||||||
|
{
|
||||||
|
"path": "/payroll/static",
|
||||||
|
"name": "payroll_static",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
|
||||||
|
payroll_ext.include_router(payroll_generic_router)
|
||||||
|
payroll_ext.include_router(payroll_api_router)
|
||||||
|
payroll_ext.include_router(payroll_employee_router)
|
||||||
|
|
||||||
|
scheduled_tasks: list[asyncio.Task] = []
|
||||||
|
|
||||||
|
|
||||||
|
def payroll_stop():
|
||||||
|
for task in scheduled_tasks:
|
||||||
|
try:
|
||||||
|
task.cancel()
|
||||||
|
except Exception as ex: # shutdown must not raise
|
||||||
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
|
def payroll_start():
|
||||||
|
from lnbits.tasks import create_permanent_unique_task
|
||||||
|
|
||||||
|
from .tasks import scheduler_loop
|
||||||
|
|
||||||
|
scheduled_tasks.append(
|
||||||
|
create_permanent_unique_task("ext_payroll_scheduler", scheduler_loop)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"db",
|
||||||
|
"payroll_ext",
|
||||||
|
"payroll_start",
|
||||||
|
"payroll_static_files",
|
||||||
|
"payroll_stop",
|
||||||
|
]
|
||||||
67
accounts.py
Normal file
67
accounts.py
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Account directory — the read model behind the operator's user picker.
|
||||||
|
|
||||||
|
Payroll needs to answer one question core does not expose in a
|
||||||
|
payroll-shaped way: *which accounts exist, and which wallets could a
|
||||||
|
payout land in?* This module builds that list out of core's own crud, so
|
||||||
|
there is no second copy of the account table anywhere in this extension.
|
||||||
|
|
||||||
|
It reads accounts the operator does not own, which is exactly why every
|
||||||
|
caller of it is gated on `check_super_user` at the API boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_accounts, get_wallets
|
||||||
|
from lnbits.db import Filters
|
||||||
|
|
||||||
|
from .models import DirectoryUser, DirectoryWallet
|
||||||
|
|
||||||
|
# Core paginates accounts and defaults to 10 per page. A payroll picker
|
||||||
|
# wants the whole roster in one go; this cap keeps a pathological instance
|
||||||
|
# from building a five-figure dropdown, and the search box narrows anything
|
||||||
|
# larger server-side.
|
||||||
|
MAX_DIRECTORY_USERS = 500
|
||||||
|
|
||||||
|
|
||||||
|
async def list_directory_users(
|
||||||
|
search: str | None = None, limit: int = MAX_DIRECTORY_USERS
|
||||||
|
) -> list[DirectoryUser]:
|
||||||
|
"""Accounts with their (non-deleted) wallets, for the payout picker.
|
||||||
|
|
||||||
|
Accounts with no wallet are still returned: they are legitimate payroll
|
||||||
|
targets, they just need a wallet created before a contract can point at
|
||||||
|
one. Hiding them would make the operator wonder why a user they can see
|
||||||
|
in the admin UI is missing here.
|
||||||
|
"""
|
||||||
|
page = await get_accounts(
|
||||||
|
filters=Filters(search=search or None, limit=min(limit, MAX_DIRECTORY_USERS))
|
||||||
|
)
|
||||||
|
|
||||||
|
users: list[DirectoryUser] = []
|
||||||
|
for account in page.data:
|
||||||
|
wallets = await get_wallets(account.id)
|
||||||
|
users.append(
|
||||||
|
DirectoryUser(
|
||||||
|
id=account.id,
|
||||||
|
username=account.username,
|
||||||
|
email=account.email,
|
||||||
|
wallets=[
|
||||||
|
DirectoryWallet(id=w.id, name=w.name, balance_msat=w.balance_msat)
|
||||||
|
for w in wallets
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Readable ordering for a dropdown; core sorts by whatever the page
|
||||||
|
# query returned, which is account id.
|
||||||
|
users.sort(key=lambda u: (u.display_name or "").lower())
|
||||||
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
async def owns_wallet(user_id: str, wallet_id: str) -> bool:
|
||||||
|
"""Whether `wallet_id` belongs to `user_id`.
|
||||||
|
|
||||||
|
The guard that stops a payroll contract from being pointed at a wallet
|
||||||
|
belonging to somebody other than the selected employee — the id pair
|
||||||
|
arrives from a form, and nothing else in the write path re-checks it.
|
||||||
|
"""
|
||||||
|
wallets = await get_wallets(user_id)
|
||||||
|
return any(w.id == wallet_id for w in wallets)
|
||||||
75
crud.py
75
crud.py
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import Contract, ContractStatus, CreateContract
|
from .models import Contract, ContractStatus, CreateContract, Payout, PayoutStatus
|
||||||
|
|
||||||
db = Database("ext_payroll")
|
db = Database("ext_payroll")
|
||||||
|
|
||||||
|
|
@ -87,3 +87,76 @@ async def delete_contract(contract_id: str) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"DELETE FROM payroll.contracts WHERE id = :id", {"id": contract_id}
|
"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
|
||||||
|
|
|
||||||
166
docs/operations.md
Normal file
166
docs/operations.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# Running payroll
|
||||||
|
|
||||||
|
## The scheduler
|
||||||
|
|
||||||
|
One permanent task (`ext_payroll_scheduler`), started with the extension.
|
||||||
|
It waits ~20s after boot, then every 5 minutes asks each **active**
|
||||||
|
contract whether it owes a payday.
|
||||||
|
|
||||||
|
Minutes, not seconds, on purpose: a payday is a calendar event, so paying
|
||||||
|
a few minutes into the day costs nothing, and a tight loop would only
|
||||||
|
multiply log noise while a contract cannot be funded. The post-boot pass
|
||||||
|
means an instance that was down over a payday catches up on restart rather
|
||||||
|
than waiting a full interval.
|
||||||
|
|
||||||
|
Days are **UTC**. An instance whose operators think in a far-eastern or
|
||||||
|
far-western timezone will see paydays land on what is locally the previous
|
||||||
|
or next day.
|
||||||
|
|
||||||
|
## How one period is paid
|
||||||
|
|
||||||
|
```
|
||||||
|
create_invoice(employee_wallet, amount, currency, internal=True)
|
||||||
|
│ ← this is also where a fiat contract is priced in sats
|
||||||
|
▼
|
||||||
|
invoice.amount (msat) ← canonical for this period, recorded as-is
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
pay_invoice(source_wallet, invoice.bolt11)
|
||||||
|
```
|
||||||
|
|
||||||
|
A plain internal LNbits wallet-to-wallet transfer. The invoice carries
|
||||||
|
`extra = {tag: "payroll", contract_id, period, payday}`, so a payroll
|
||||||
|
transfer is identifiable from the payments list on either wallet.
|
||||||
|
|
||||||
|
The sat amount is derived exactly once, by LNbits' own invoice pricing, and
|
||||||
|
everything downstream reuses that number. Re-deriving it from
|
||||||
|
`amount × rate` at any later point would drift — FX moves between quote and
|
||||||
|
settlement, and rounding accumulates over a year of paydays.
|
||||||
|
|
||||||
|
If a payout fails after the invoice exists, an unpaid internal invoice is
|
||||||
|
left on the employee's wallet. It was never settled and expires on its own;
|
||||||
|
that is the deliberate price of not pricing the period a second time just
|
||||||
|
to run a balance check.
|
||||||
|
|
||||||
|
## What a failure does
|
||||||
|
|
||||||
|
A failed period does **not** advance `periods_done`, so the next tick
|
||||||
|
retries the same payday. Later periods do not jump the queue: the backlog
|
||||||
|
halts at the first failure so paydays settle in order.
|
||||||
|
|
||||||
|
The common failure is an underfunded source wallet, and the log line says
|
||||||
|
so explicitly with both figures:
|
||||||
|
|
||||||
|
```
|
||||||
|
payroll: contract a1b2c3d4 period 3 failed: insufficient balance in source
|
||||||
|
wallet: 12000 sat available, 80000 sat required
|
||||||
|
```
|
||||||
|
|
||||||
|
Retries are bounded. After `MAX_PERIOD_ATTEMPTS` (5) failures on the *same*
|
||||||
|
period, the contract is **paused** and the operator has to act. Pausing
|
||||||
|
rather than abandoning the period is the point: a payday that cannot be
|
||||||
|
funded is a fact somebody needs to see, and silently dropping it is the one
|
||||||
|
outcome payroll must never produce. Because pausing does not advance the
|
||||||
|
position, resuming after topping up the source wallet retries that same
|
||||||
|
payday.
|
||||||
|
|
||||||
|
## The payout ledger
|
||||||
|
|
||||||
|
Every attempt — paid, skipped *and* failed — is written to
|
||||||
|
`payroll.payouts`. `GET /payroll/api/v1/payouts` returns them newest first,
|
||||||
|
filterable by `contract_id` and `status`. "Why did nobody get paid on the
|
||||||
|
1st" is the question the ledger exists to answer, which is why failures are
|
||||||
|
in it rather than only in the log.
|
||||||
|
|
||||||
|
Rows are self-describing: each one copies the terms in force at the time
|
||||||
|
(amount, currency, both wallet ids) rather than pointing at the contract,
|
||||||
|
so a payout still reads correctly after its contract is edited or deleted.
|
||||||
|
There is deliberately no foreign key to `contracts` — deleting a contract
|
||||||
|
must not take its history with it.
|
||||||
|
|
||||||
|
The attempt counter is derived by counting a period's prior failures in the
|
||||||
|
ledger, not from a column on the contract, so it survives a restart and the
|
||||||
|
rows that produced the number are right there to audit.
|
||||||
|
|
||||||
|
## Back-dated start dates
|
||||||
|
|
||||||
|
Creating a contract with a start date in the past is normally an
|
||||||
|
*anchoring* choice — "we pay on the 1st" — not a request for back-pay.
|
||||||
|
Periods whose payday fell before the contract row existed are therefore
|
||||||
|
**skipped**, and skipping still consumes the period, so the schedule stays
|
||||||
|
aligned to the anchor.
|
||||||
|
|
||||||
|
Tick `backfill` on the contract to pay them instead.
|
||||||
|
|
||||||
|
Either way a single tick will not fire more than `MAX_CATCH_UP_PERIODS`
|
||||||
|
(12) periods for one contract, so a mistyped start date cannot turn into a
|
||||||
|
hundred transfers.
|
||||||
|
|
||||||
|
## Double-payment guard
|
||||||
|
|
||||||
|
Each contract has an in-process `asyncio.Lock`, and `run_due_periods`
|
||||||
|
re-reads the contract row under it. The scheduler is a single task, so the
|
||||||
|
lock exists for the off-cycle paths that can land mid-tick.
|
||||||
|
|
||||||
|
This is per-process. Two LNbits processes sharing one database would not
|
||||||
|
be serialised by it — payroll assumes the single-writer deployment LNbits
|
||||||
|
itself assumes.
|
||||||
|
|
||||||
|
## Previewing a schedule
|
||||||
|
|
||||||
|
`POST /payroll/api/v1/schedule/preview` draws a calendar from loose terms
|
||||||
|
(`start_date`, `frequency`, `total_periods`) — no employee or wallet
|
||||||
|
required, because the schedule is usually the thing worth sanity-checking
|
||||||
|
first, and a mistyped start date is cheapest to fix before anything is
|
||||||
|
saved. `GET /payroll/api/v1/contracts/{id}/schedule` does the same for a
|
||||||
|
live contract, from its current position.
|
||||||
|
|
||||||
|
Both return `ends_on`, the contract's last payday. "12 monthly payments
|
||||||
|
from 15 Jan" is much easier to check against "ends 15 Dec" than against a
|
||||||
|
list of twelve dates.
|
||||||
|
|
||||||
|
## Paying off-cycle
|
||||||
|
|
||||||
|
`POST /payroll/api/v1/contracts/{id}/pay-now` settles the contract's next
|
||||||
|
period immediately, whatever the calendar says, and consumes it.
|
||||||
|
|
||||||
|
One endpoint covers both "run it now" (don't wait for the tick) and "pay it
|
||||||
|
early", because they are the same operation — they differ only in whether
|
||||||
|
today happens to be the payday. It bypasses the back-dated skip: that guard
|
||||||
|
exists to stop a *new* contract firing surprise back-pay, and an operator
|
||||||
|
explicitly asking to pay a period is not a surprise.
|
||||||
|
|
||||||
|
It is recorded in the ledger like any other payout, with the early payment
|
||||||
|
noted in `detail`, and the ledger row is returned even when the payout
|
||||||
|
failed — a manual payout that did not land is exactly when you want to know
|
||||||
|
why.
|
||||||
|
|
||||||
|
## Accounting export
|
||||||
|
|
||||||
|
`GET /payroll/api/v1/payouts.csv` (super user) exports the ledger, with
|
||||||
|
optional `contract_id`, `status`, `since` and `until`. `since`/`until` bound
|
||||||
|
the **payday**, not the row timestamp, so an accounting period contains the
|
||||||
|
paydays that belong to it even when one took three days of retries to
|
||||||
|
settle.
|
||||||
|
|
||||||
|
Every exported field is neutralised against spreadsheet formula injection —
|
||||||
|
a cell beginning `=`, `+`, `-` or `@` is prefixed with an apostrophe.
|
||||||
|
`detail` carries exception text and the memo carries operator input, and
|
||||||
|
neither is worth trusting to a colleague's Excel.
|
||||||
|
|
||||||
|
## What an employee can see
|
||||||
|
|
||||||
|
`/payroll/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts` are
|
||||||
|
gated on a **wallet invoice key**, not on admin rights: whoever holds the
|
||||||
|
read key for a wallet may see what payroll has paid into that wallet, and
|
||||||
|
nothing else. They live on a separate router from the super-user API so
|
||||||
|
they cannot inherit — or accidentally shed — the wrong gate.
|
||||||
|
|
||||||
|
Scoped to the wallet rather than to the account on purpose: an invoice key
|
||||||
|
names exactly one wallet, so there is no lookup that could widen the result
|
||||||
|
to a sibling wallet the key does not cover.
|
||||||
|
|
||||||
|
Note this stays reachable even with `payroll` in `LNBITS_ADMIN_EXTENSIONS`.
|
||||||
|
That flag governs who sees the extension in the UI; the API routes are
|
||||||
|
mounted instance-wide, and these three are gated by the wallet key they
|
||||||
|
require rather than by UI visibility.
|
||||||
70
export.py
Normal file
70
export.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""CSV export of the payout ledger.
|
||||||
|
|
||||||
|
Payroll data leaves this extension for a spreadsheet, and a spreadsheet
|
||||||
|
treats a cell beginning with `=`, `+`, `-` or `@` as a formula. Every field
|
||||||
|
that reaches a cell is therefore neutralised on the way out — `detail`
|
||||||
|
carries exception text and the memo carries operator input, and neither is
|
||||||
|
worth trusting to a colleague's Excel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from .models import Payout
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
"payday",
|
||||||
|
"status",
|
||||||
|
"attempt",
|
||||||
|
"amount",
|
||||||
|
"currency",
|
||||||
|
"amount_sat",
|
||||||
|
"contract_id",
|
||||||
|
"period_index",
|
||||||
|
"employee_id",
|
||||||
|
"employee_wallet",
|
||||||
|
"source_wallet",
|
||||||
|
"payment_hash",
|
||||||
|
"detail",
|
||||||
|
"recorded_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Leading characters a spreadsheet reads as the start of a formula.
|
||||||
|
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
||||||
|
|
||||||
|
|
||||||
|
def csv_safe(value) -> str:
|
||||||
|
"""Render one value so a spreadsheet treats it as text, never a formula."""
|
||||||
|
text = "" if value is None else str(value)
|
||||||
|
if text.startswith(_FORMULA_PREFIXES):
|
||||||
|
return "'" + text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def payouts_to_csv(payouts: list[Payout]) -> str:
|
||||||
|
buffer = io.StringIO()
|
||||||
|
writer = csv.writer(buffer)
|
||||||
|
writer.writerow(COLUMNS)
|
||||||
|
for p in payouts:
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
csv_safe(v)
|
||||||
|
for v in (
|
||||||
|
p.payday,
|
||||||
|
p.status.value,
|
||||||
|
p.attempt,
|
||||||
|
p.amount,
|
||||||
|
p.currency,
|
||||||
|
p.amount_sat,
|
||||||
|
p.contract_id,
|
||||||
|
p.period_index,
|
||||||
|
p.employee_id,
|
||||||
|
p.employee_wallet,
|
||||||
|
p.source_wallet,
|
||||||
|
p.payment_hash,
|
||||||
|
p.detail,
|
||||||
|
p.created_at.isoformat(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
@ -16,8 +16,7 @@ schema.
|
||||||
async def m001_initial(db):
|
async def m001_initial(db):
|
||||||
"""Payroll contracts — the standing "pay X this much, this often" rows."""
|
"""Payroll contracts — the standing "pay X this much, this often" rows."""
|
||||||
|
|
||||||
await db.execute(
|
await db.execute(f"""
|
||||||
f"""
|
|
||||||
CREATE TABLE payroll.contracts (
|
CREATE TABLE payroll.contracts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
employee_id TEXT NOT NULL,
|
employee_id TEXT NOT NULL,
|
||||||
|
|
@ -37,17 +36,55 @@ async def m001_initial(db):
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||||
updated_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".
|
# The scheduler's hot path is "every contract still eligible to pay".
|
||||||
# NOTE: the schema qualifier goes on the INDEX name, not the table —
|
# NOTE: the schema qualifier goes on the INDEX name, not the table —
|
||||||
# SQLite attaches the extension DB as schema `payroll`, and
|
# SQLite attaches the extension DB as schema `payroll`, and
|
||||||
# `CREATE INDEX ON schema.table` is a syntax error there.
|
# `CREATE INDEX ON schema.table` is a syntax error there.
|
||||||
await db.execute(
|
await db.execute("CREATE INDEX payroll.idx_contracts_status ON contracts (status);")
|
||||||
"CREATE INDEX payroll.idx_contracts_status ON contracts (status);"
|
|
||||||
)
|
|
||||||
# Employee-facing lookups ("show me my payroll lines") filter by account.
|
# Employee-facing lookups ("show me my payroll lines") filter by account.
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"CREATE INDEX payroll.idx_contracts_employee ON contracts (employee_id);"
|
"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);"
|
||||||
|
)
|
||||||
|
|
|
||||||
91
models.py
91
models.py
|
|
@ -191,3 +191,94 @@ class DirectoryUser(BaseModel):
|
||||||
@property
|
@property
|
||||||
def display_name(self) -> str:
|
def display_name(self) -> str:
|
||||||
return self.username or self.email or self.id
|
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
Normal file
598
services.py
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
"""Payroll scheduling and payout.
|
||||||
|
|
||||||
|
Two separable things live here, and the split is deliberate:
|
||||||
|
|
||||||
|
* **Schedule math** (`occurrence_on`, `due_period_indices`) is pure — no
|
||||||
|
DB, no wallets, no clock of its own. Paydays are the part of payroll that
|
||||||
|
is easy to get subtly wrong and expensive to get wrong in production, so
|
||||||
|
it is kept testable in isolation.
|
||||||
|
* **Payout** (`run_due_periods`) is the effectful half: it turns a due
|
||||||
|
period into an actual wallet-to-wallet transfer.
|
||||||
|
|
||||||
|
Everything is anchored on `start_date`. The *n*-th payday is a function of
|
||||||
|
the start date and `n` alone — never of the previous payday, and never of
|
||||||
|
when the scheduler happened to run. That is what stops a missed tick, a
|
||||||
|
restart, or a short month from shifting the whole remaining schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from calendar import monthrange
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from lnbits.core.crud import get_wallet
|
||||||
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from . import crud
|
||||||
|
from .models import (
|
||||||
|
TERMINAL_STATUSES,
|
||||||
|
Contract,
|
||||||
|
ContractStatus,
|
||||||
|
Frequency,
|
||||||
|
Payout,
|
||||||
|
PayoutStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Frequencies that are an exact number of days: no calendar involved, so no
|
||||||
|
# clamping is possible or needed.
|
||||||
|
_DAY_STEP = {
|
||||||
|
Frequency.daily: 1,
|
||||||
|
Frequency.weekly: 7,
|
||||||
|
Frequency.biweekly: 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frequencies that step whole months and therefore clamp into short months.
|
||||||
|
_MONTH_STEP = {
|
||||||
|
Frequency.monthly: 1,
|
||||||
|
Frequency.quarterly: 3,
|
||||||
|
Frequency.yearly: 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
# A single tick will not fire more than this many periods for one contract.
|
||||||
|
# Protects against a mistyped start date turning into a hundred transfers.
|
||||||
|
MAX_CATCH_UP_PERIODS = 12
|
||||||
|
|
||||||
|
# How many times one period may fail before the contract is paused for the
|
||||||
|
# operator to look at. Retrying an underfunded wallet forever is not
|
||||||
|
# resilience, it is a log the operator learns to ignore — and a missed
|
||||||
|
# payday deserves to be visible in the UI, not buried in journalctl.
|
||||||
|
MAX_PERIOD_ATTEMPTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schedule math (pure)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def add_months(anchor: date, months: int) -> date:
|
||||||
|
"""`anchor` shifted by `months`, clamped to the target month's last day.
|
||||||
|
|
||||||
|
31 Jan + 1 month is 28 Feb (29 in a leap year), because 31 Feb does not
|
||||||
|
exist. Note this is applied to the *anchor*, not to a previous result:
|
||||||
|
31 Jan + 2 months is 31 Mar, not 28 Mar. Payroll anchored on month-end
|
||||||
|
should keep paying on month-end.
|
||||||
|
"""
|
||||||
|
total = anchor.year * 12 + (anchor.month - 1) + months
|
||||||
|
year, month_index = divmod(total, 12)
|
||||||
|
month = month_index + 1
|
||||||
|
return date(year, month, min(anchor.day, monthrange(year, month)[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def occurrence_on(start: date, frequency: Frequency, index: int) -> date:
|
||||||
|
"""The payday for period `index`, counting the start date as period 0."""
|
||||||
|
if frequency in _DAY_STEP:
|
||||||
|
return start + timedelta(days=_DAY_STEP[frequency] * index)
|
||||||
|
return add_months(start, _MONTH_STEP[frequency] * index)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_start_date(contract: Contract) -> date:
|
||||||
|
return datetime.strptime(contract.start_date, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def next_payday(contract: Contract) -> date | None:
|
||||||
|
"""When this contract pays next, or None if it has no periods left."""
|
||||||
|
remaining = contract.periods_remaining
|
||||||
|
if remaining is not None and remaining <= 0:
|
||||||
|
return None
|
||||||
|
return occurrence_on(
|
||||||
|
parse_start_date(contract), contract.frequency, contract.periods_done
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def paydays_from(
|
||||||
|
start: date,
|
||||||
|
frequency: Frequency,
|
||||||
|
first_index: int,
|
||||||
|
count: int,
|
||||||
|
total_periods: int | None = None,
|
||||||
|
) -> list[date]:
|
||||||
|
"""`count` paydays starting at period `first_index`, truncated by any cap.
|
||||||
|
|
||||||
|
Takes loose parameters rather than a Contract so the operator can preview
|
||||||
|
a schedule *before* the contract exists — which is the point at which a
|
||||||
|
mistyped start date or the wrong frequency is cheap to fix.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
occurrence_on(start, frequency, i)
|
||||||
|
for i in range(first_index, first_index + count)
|
||||||
|
if total_periods is None or i < total_periods
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upcoming_paydays(contract: Contract, count: int) -> list[date]:
|
||||||
|
"""The next `count` paydays for a live contract, from its position."""
|
||||||
|
return paydays_from(
|
||||||
|
parse_start_date(contract),
|
||||||
|
contract.frequency,
|
||||||
|
contract.periods_done,
|
||||||
|
count,
|
||||||
|
contract.total_periods,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def final_payday(
|
||||||
|
start: date, frequency: Frequency, total_periods: int | None
|
||||||
|
) -> date | None:
|
||||||
|
"""When an open-ended contract would end — None, by definition."""
|
||||||
|
if total_periods is None:
|
||||||
|
return None
|
||||||
|
return occurrence_on(start, frequency, total_periods - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def due_period_indices(contract: Contract, today: date) -> list[int]:
|
||||||
|
"""Every period index that is payable as of `today`, in order.
|
||||||
|
|
||||||
|
Normally this is zero or one entry. It is a list because a contract can
|
||||||
|
legitimately have a backlog: a back-dated start date, or an instance
|
||||||
|
that was down over a payday. Returning them all lets the caller decide
|
||||||
|
what to do with each — `run_due_periods` pays or skips them one at a
|
||||||
|
time rather than collapsing a backlog into a single surprise transfer.
|
||||||
|
"""
|
||||||
|
if contract.status != ContractStatus.active:
|
||||||
|
return []
|
||||||
|
|
||||||
|
start = parse_start_date(contract)
|
||||||
|
cap = contract.total_periods
|
||||||
|
indices: list[int] = []
|
||||||
|
index = contract.periods_done
|
||||||
|
while cap is None or index < cap:
|
||||||
|
if occurrence_on(start, contract.frequency, index) > today:
|
||||||
|
break
|
||||||
|
indices.append(index)
|
||||||
|
index += 1
|
||||||
|
# A daily contract left unpaid for years would otherwise build an
|
||||||
|
# unbounded list. Anything beyond this is an operator problem, not
|
||||||
|
# something to silently drain in one tick.
|
||||||
|
if len(indices) >= MAX_CATCH_UP_PERIODS:
|
||||||
|
break
|
||||||
|
return indices
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payout (effectful)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PeriodOutcome:
|
||||||
|
"""What happened to one period. `amount_msat` is the canonical settled
|
||||||
|
figure, taken from the invoice LNbits actually priced — never
|
||||||
|
recomputed from amount x rate."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
payday: date
|
||||||
|
status: PayoutStatus
|
||||||
|
detail: str = ""
|
||||||
|
amount_msat: int | None = None
|
||||||
|
payment_hash: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def consumed(self) -> bool:
|
||||||
|
"""Whether this period should advance the contract's position.
|
||||||
|
|
||||||
|
Paid and skipped both consume the period. Failed does not — that is
|
||||||
|
what makes the next tick retry the same payday rather than dropping
|
||||||
|
it.
|
||||||
|
"""
|
||||||
|
return self.status in (PayoutStatus.paid, PayoutStatus.skipped)
|
||||||
|
|
||||||
|
|
||||||
|
# One lock per contract id. The scheduler is a single task, but an operator
|
||||||
|
# can trigger an off-cycle payout at any moment; without this, a manual run
|
||||||
|
# landing mid-tick could pay the same period twice. Double-paying is the
|
||||||
|
# worst thing this extension can do, so the guard is cheap insurance.
|
||||||
|
_contract_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_for(contract_id: str) -> asyncio.Lock:
|
||||||
|
lock = _contract_locks.get(contract_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_contract_locks[contract_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def payout_memo(contract: Contract, payday: date) -> str:
|
||||||
|
label = contract.memo or contract.label or "Payroll"
|
||||||
|
return f"{label} — {payday.isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
||||||
|
"""Move one period's money from the source wallet to the employee wallet.
|
||||||
|
|
||||||
|
An internal invoice on the destination wallet, paid from the source
|
||||||
|
wallet — the standard LNbits wallet-to-wallet transfer. Creating the
|
||||||
|
invoice is also what prices a fiat contract in sats, so the amount is
|
||||||
|
converted exactly once, here, and the result is what gets recorded.
|
||||||
|
|
||||||
|
A failure after the invoice exists leaves an unpaid internal invoice on
|
||||||
|
the employee's wallet. That is harmless — it expires on its own and was
|
||||||
|
never settled — and it is the price of not pre-computing the sat amount
|
||||||
|
a second time just to run a balance check.
|
||||||
|
"""
|
||||||
|
memo = payout_memo(contract, payday)
|
||||||
|
extra = {
|
||||||
|
"tag": "payroll",
|
||||||
|
"contract_id": contract.id,
|
||||||
|
"period": index,
|
||||||
|
"payday": payday.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
invoice = await create_invoice(
|
||||||
|
wallet_id=contract.employee_wallet,
|
||||||
|
amount=contract.amount,
|
||||||
|
currency=contract.currency,
|
||||||
|
memo=memo,
|
||||||
|
internal=True,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
# Broad: pricing, wallet limits and funding-source errors all surface
|
||||||
|
# here, and every one of them is a failed period rather than a crash.
|
||||||
|
except Exception as exc:
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.failed,
|
||||||
|
detail=f"could not raise invoice: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# invoice.amount is msat and is now the canonical figure for this period.
|
||||||
|
amount_msat = invoice.amount
|
||||||
|
|
||||||
|
source = await get_wallet(contract.source_wallet)
|
||||||
|
if not source:
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.failed,
|
||||||
|
detail="source wallet not found",
|
||||||
|
amount_msat=amount_msat,
|
||||||
|
)
|
||||||
|
if source.balance_msat < amount_msat:
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.failed,
|
||||||
|
detail=(
|
||||||
|
f"insufficient balance in source wallet: "
|
||||||
|
f"{source.balance_msat // 1000} sat available, "
|
||||||
|
f"{amount_msat // 1000} sat required"
|
||||||
|
),
|
||||||
|
amount_msat=amount_msat,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await pay_invoice(
|
||||||
|
wallet_id=contract.source_wallet,
|
||||||
|
payment_request=invoice.bolt11,
|
||||||
|
description=memo,
|
||||||
|
tag="payroll",
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
except Exception as exc: # a failed payment is an expected outcome
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.failed,
|
||||||
|
detail=f"payment failed: {exc}",
|
||||||
|
amount_msat=amount_msat,
|
||||||
|
payment_hash=invoice.payment_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.paid,
|
||||||
|
amount_msat=amount_msat,
|
||||||
|
payment_hash=invoice.payment_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_due_periods(
|
||||||
|
contract: Contract, today: date | None = None
|
||||||
|
) -> list[Payout]:
|
||||||
|
"""Settle every period this contract owes as of `today`.
|
||||||
|
|
||||||
|
Every attempt — paid, skipped or failed — is written to the ledger, and
|
||||||
|
the ledger rows are what this returns.
|
||||||
|
|
||||||
|
Stops at the first failure so a backlog cannot pay periods out of order:
|
||||||
|
if period 3 could not be funded, period 4 waits for it. Both will be
|
||||||
|
retried on the next tick, because a failed period does not advance the
|
||||||
|
contract's position.
|
||||||
|
"""
|
||||||
|
today = today or datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
async with _lock_for(contract.id):
|
||||||
|
# Re-read under the lock: a manual run may have moved the position
|
||||||
|
# since the caller loaded this row.
|
||||||
|
fresh = await crud.get_contract(contract.id)
|
||||||
|
if not fresh:
|
||||||
|
return []
|
||||||
|
contract = fresh
|
||||||
|
|
||||||
|
payouts: list[Payout] = []
|
||||||
|
for index in due_period_indices(contract, today):
|
||||||
|
payday = occurrence_on(
|
||||||
|
parse_start_date(contract), contract.frequency, index
|
||||||
|
)
|
||||||
|
outcome = await _settle(contract, index, payday)
|
||||||
|
payout = await _record(contract, outcome)
|
||||||
|
payouts.append(payout)
|
||||||
|
|
||||||
|
if not outcome.consumed:
|
||||||
|
_maybe_pause_after_repeated_failure(contract, payout)
|
||||||
|
break
|
||||||
|
contract.periods_done = index + 1
|
||||||
|
|
||||||
|
if payouts:
|
||||||
|
_maybe_complete(contract)
|
||||||
|
await crud.update_contract(contract)
|
||||||
|
|
||||||
|
return payouts
|
||||||
|
|
||||||
|
|
||||||
|
async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout:
|
||||||
|
"""Write one attempt to the ledger.
|
||||||
|
|
||||||
|
The attempt number is derived by counting this period's prior failures
|
||||||
|
in the ledger itself rather than from a counter on the contract, so it
|
||||||
|
survives a restart and the rows that produced it are right there to
|
||||||
|
audit. A paid or skipped period is never retried, so it is always
|
||||||
|
attempt 1 as far as that outcome is concerned.
|
||||||
|
"""
|
||||||
|
attempt = 1
|
||||||
|
if outcome.status == PayoutStatus.failed:
|
||||||
|
attempt = await crud.count_period_failures(contract.id, outcome.index) + 1
|
||||||
|
|
||||||
|
return await crud.create_payout(
|
||||||
|
Payout(
|
||||||
|
id=urlsafe_short_hash()[:12],
|
||||||
|
contract_id=contract.id,
|
||||||
|
period_index=outcome.index,
|
||||||
|
payday=outcome.payday.isoformat(),
|
||||||
|
status=PayoutStatus(outcome.status),
|
||||||
|
attempt=attempt,
|
||||||
|
amount_msat=outcome.amount_msat,
|
||||||
|
# Terms in force at the time — a ledger row has to still read
|
||||||
|
# correctly after the contract is edited or deleted.
|
||||||
|
amount=contract.amount,
|
||||||
|
currency=contract.currency,
|
||||||
|
employee_id=contract.employee_id,
|
||||||
|
employee_wallet=contract.employee_wallet,
|
||||||
|
source_wallet=contract.source_wallet,
|
||||||
|
payment_hash=outcome.payment_hash,
|
||||||
|
detail=outcome.detail,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_pause_after_repeated_failure(contract: Contract, payout: Payout) -> None:
|
||||||
|
"""Give up retrying a period and hand it to the operator.
|
||||||
|
|
||||||
|
The contract is paused rather than the period abandoned: a payday that
|
||||||
|
cannot be funded is a fact somebody needs to act on, and silently
|
||||||
|
dropping it is the one outcome payroll must never produce. Resuming
|
||||||
|
after fixing the funding retries the same period, because pausing did
|
||||||
|
not advance the position.
|
||||||
|
"""
|
||||||
|
if payout.attempt < MAX_PERIOD_ATTEMPTS:
|
||||||
|
return
|
||||||
|
contract.status = ContractStatus.paused
|
||||||
|
logger.error(
|
||||||
|
f"payroll: contract {contract.id} paused after {payout.attempt} failed "
|
||||||
|
f"attempts at period {payout.period_index} ({payout.payday}): "
|
||||||
|
f"{payout.detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
||||||
|
"""Pay a period, or skip it as back-dated.
|
||||||
|
|
||||||
|
A contract created with a start date in the past is normally an
|
||||||
|
*anchoring* choice — "we pay on the 1st" — not a request for back-pay.
|
||||||
|
Periods whose payday fell before the contract existed are therefore
|
||||||
|
skipped unless the operator explicitly asked to backfill, which keeps a
|
||||||
|
new contract from firing months of transfers on its first tick.
|
||||||
|
"""
|
||||||
|
if not contract.backfill and payday < contract.created_at.date():
|
||||||
|
logger.info(
|
||||||
|
f"payroll: contract {contract.id} period {index} ({payday}) "
|
||||||
|
"predates the contract; skipping (backfill is off)"
|
||||||
|
)
|
||||||
|
return PeriodOutcome(
|
||||||
|
index=index,
|
||||||
|
payday=payday,
|
||||||
|
status=PayoutStatus.skipped,
|
||||||
|
detail="payday predates the contract and backfill is off",
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await pay_period(contract, index, payday)
|
||||||
|
if outcome.status == PayoutStatus.paid:
|
||||||
|
logger.success(
|
||||||
|
f"payroll: contract {contract.id} period {index} paid "
|
||||||
|
f"{(outcome.amount_msat or 0) // 1000} sat to "
|
||||||
|
f"{contract.employee_wallet}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"payroll: contract {contract.id} period {index} "
|
||||||
|
f"{outcome.status.value}: {outcome.detail}"
|
||||||
|
)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_complete(contract: Contract) -> None:
|
||||||
|
# Only an active contract completes: a period that just exhausted the
|
||||||
|
# retry budget has paused this one, and that has to stick.
|
||||||
|
if contract.status != ContractStatus.active:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
contract.total_periods is not None
|
||||||
|
and contract.periods_done >= contract.total_periods
|
||||||
|
):
|
||||||
|
contract.status = ContractStatus.completed
|
||||||
|
logger.info(
|
||||||
|
f"payroll: contract {contract.id} completed "
|
||||||
|
f"({contract.periods_done} periods)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def tick(today: date | None = None) -> None:
|
||||||
|
"""One scheduler pass over every active contract."""
|
||||||
|
contracts = await crud.get_contracts_by_status(ContractStatus.active)
|
||||||
|
for contract in contracts:
|
||||||
|
try:
|
||||||
|
await run_due_periods(contract, today)
|
||||||
|
except Exception as exc: # one bad row must not stop the rest of payroll
|
||||||
|
logger.error(f"payroll: contract {contract.id} tick failed: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleError(ValueError):
|
||||||
|
"""An illegal status transition. Mapped to 409 at the API boundary."""
|
||||||
|
|
||||||
|
|
||||||
|
def fast_forward_index(contract: Contract, today: date) -> int:
|
||||||
|
"""The first period index whose payday is not already in the past."""
|
||||||
|
start = parse_start_date(contract)
|
||||||
|
cap = contract.total_periods
|
||||||
|
index = contract.periods_done
|
||||||
|
while (cap is None or index < cap) and occurrence_on(
|
||||||
|
start, contract.frequency, index
|
||||||
|
) < today:
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def pause(contract: Contract) -> Contract:
|
||||||
|
if contract.status != ContractStatus.active:
|
||||||
|
raise LifecycleError(
|
||||||
|
f"Only an active contract can be paused (is {contract.status.value})."
|
||||||
|
)
|
||||||
|
contract.status = ContractStatus.paused
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def resume(
|
||||||
|
contract: Contract, today: date | None = None, catch_up: bool = False
|
||||||
|
) -> Contract:
|
||||||
|
"""Put a paused contract back to work.
|
||||||
|
|
||||||
|
By default the paydays that fell during the pause are *not* paid: a
|
||||||
|
pause is a decision not to pay them, and resuming into a surprise
|
||||||
|
multi-period transfer is the opposite of what an operator asking to
|
||||||
|
resume expects. The contract's position is fast-forwarded to the next
|
||||||
|
payday on or after today instead.
|
||||||
|
|
||||||
|
`catch_up=True` opts into paying them, for the case where the pause was
|
||||||
|
an operational hold rather than a decision about the money.
|
||||||
|
"""
|
||||||
|
if contract.status != ContractStatus.paused:
|
||||||
|
raise LifecycleError(
|
||||||
|
f"Only a paused contract can be resumed (is {contract.status.value})."
|
||||||
|
)
|
||||||
|
|
||||||
|
contract.status = ContractStatus.active
|
||||||
|
if not catch_up:
|
||||||
|
today = today or datetime.now(timezone.utc).date()
|
||||||
|
skipped_to = fast_forward_index(contract, today)
|
||||||
|
if skipped_to != contract.periods_done:
|
||||||
|
logger.info(
|
||||||
|
f"payroll: contract {contract.id} resumed, skipping "
|
||||||
|
f"{skipped_to - contract.periods_done} payday(s) missed while paused"
|
||||||
|
)
|
||||||
|
contract.periods_done = skipped_to
|
||||||
|
_maybe_complete(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
def cancel(contract: Contract) -> Contract:
|
||||||
|
"""Stop a contract for good, keeping the row and its history.
|
||||||
|
|
||||||
|
This — not DELETE — is how a running payroll is stopped: deleting throws
|
||||||
|
away the schedule position and the record that the contract ever existed.
|
||||||
|
"""
|
||||||
|
if contract.status in TERMINAL_STATUSES:
|
||||||
|
raise LifecycleError(f"Contract is already {contract.status.value}.")
|
||||||
|
contract.status = ContractStatus.cancelled
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Off-cycle payout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def pay_now(contract: Contract) -> Payout:
|
||||||
|
"""Settle the contract's next period immediately, whatever the calendar
|
||||||
|
says.
|
||||||
|
|
||||||
|
The operator's manual override: "run it now" when waiting five minutes
|
||||||
|
for the tick is not acceptable, and "pay it early" when a payday needs to
|
||||||
|
land before it is due. Both are the same operation — the next period
|
||||||
|
settles and is consumed — so there is one endpoint rather than two that
|
||||||
|
differ only in whether today happens to be the payday.
|
||||||
|
|
||||||
|
It calls `pay_period` directly rather than going through `_settle`,
|
||||||
|
because `_settle`'s back-dated skip exists to stop a *new* contract
|
||||||
|
firing surprise back-pay. An operator explicitly asking to pay a period
|
||||||
|
is not a surprise.
|
||||||
|
"""
|
||||||
|
async with _lock_for(contract.id):
|
||||||
|
fresh = await crud.get_contract(contract.id)
|
||||||
|
if not fresh:
|
||||||
|
raise LifecycleError("Contract not found.")
|
||||||
|
contract = fresh
|
||||||
|
|
||||||
|
if contract.status != ContractStatus.active:
|
||||||
|
raise LifecycleError(
|
||||||
|
f"Only an active contract can be paid (is {contract.status.value})."
|
||||||
|
)
|
||||||
|
remaining = contract.periods_remaining
|
||||||
|
if remaining is not None and remaining <= 0:
|
||||||
|
raise LifecycleError("Contract has no periods left to pay.")
|
||||||
|
|
||||||
|
index = contract.periods_done
|
||||||
|
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
outcome = await pay_period(contract, index, payday)
|
||||||
|
if payday > today and outcome.status == PayoutStatus.paid:
|
||||||
|
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"
|
||||||
|
|
||||||
|
payout = await _record(contract, outcome)
|
||||||
|
if outcome.consumed:
|
||||||
|
contract.periods_done = index + 1
|
||||||
|
_maybe_complete(contract)
|
||||||
|
else:
|
||||||
|
_maybe_pause_after_repeated_failure(contract, payout)
|
||||||
|
await crud.update_contract(contract)
|
||||||
|
return payout
|
||||||
491
static/js/index.js
Normal file
491
static/js/index.js
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
// Payroll operator console — LNbits page shell (Vue 3 + Quasar 2 UMD, no
|
||||||
|
// build step). Gotchas honored: no self-closing tags in the template,
|
||||||
|
// `${ }` interpolation so Jinja never sees a moustache, and `:style`
|
||||||
|
// bindings rather than <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
Normal file
36
tasks.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""The payroll scheduler.
|
||||||
|
|
||||||
|
One permanent task that wakes up, asks every active contract whether it
|
||||||
|
owes a payday, and settles the ones that do. Deliberately dumb: all the
|
||||||
|
decisions live in services.py, and this file only owns the clock.
|
||||||
|
|
||||||
|
The tick interval is minutes rather than seconds because a payday is a
|
||||||
|
calendar event — the cost of paying an hour into the day is nil, and a
|
||||||
|
tight loop would only multiply log noise when a contract cannot be funded.
|
||||||
|
A pass also runs shortly after startup so an instance that was down over a
|
||||||
|
payday catches up without waiting a full interval.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from . import services
|
||||||
|
|
||||||
|
# How often to look for due paydays.
|
||||||
|
TICK_SECONDS = 300
|
||||||
|
|
||||||
|
# Let the funding source, DB and extension registry settle before the first
|
||||||
|
# pass — the first tick can move money, so it should not race startup.
|
||||||
|
STARTUP_DELAY_SECONDS = 20
|
||||||
|
|
||||||
|
|
||||||
|
async def scheduler_loop():
|
||||||
|
await asyncio.sleep(STARTUP_DELAY_SECONDS)
|
||||||
|
logger.info("payroll: scheduler started")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await services.tick()
|
||||||
|
except Exception as exc: # the loop must outlive any single tick
|
||||||
|
logger.error(f"payroll: scheduler tick failed: {exc}")
|
||||||
|
await asyncio.sleep(TICK_SECONDS)
|
||||||
373
templates/payroll/index.html
Normal file
373
templates/payroll/index.html
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
{% 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 %}
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
43
tests/conftest.py
Normal file
43
tests/conftest.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Test fixtures for payroll.
|
||||||
|
|
||||||
|
The schedule math is pure, so most tests need nothing but a `Contract`
|
||||||
|
instance — no LNbits DB, no wallets, no event loop. That is the point of
|
||||||
|
keeping `services` split into a pure half and an effectful half.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..models import Contract, ContractStatus, Frequency
|
||||||
|
|
||||||
|
|
||||||
|
def make_contract(
|
||||||
|
*,
|
||||||
|
contract_id: str = "c1",
|
||||||
|
start_date: str = "2026-01-15",
|
||||||
|
frequency: Frequency = Frequency.monthly,
|
||||||
|
total_periods: int | None = 12,
|
||||||
|
periods_done: int = 0,
|
||||||
|
status: ContractStatus = ContractStatus.active,
|
||||||
|
amount: float = 1000,
|
||||||
|
currency: str = "sat",
|
||||||
|
backfill: bool = False,
|
||||||
|
created_at: str = "2026-01-01",
|
||||||
|
) -> Contract:
|
||||||
|
return Contract(
|
||||||
|
id=contract_id,
|
||||||
|
employee_id="employee-account",
|
||||||
|
employee_username="alice",
|
||||||
|
employee_wallet="wallet-employee",
|
||||||
|
source_wallet="wallet-treasury",
|
||||||
|
amount=amount,
|
||||||
|
currency=currency,
|
||||||
|
frequency=frequency,
|
||||||
|
start_date=start_date,
|
||||||
|
total_periods=total_periods,
|
||||||
|
periods_done=periods_done,
|
||||||
|
status=status,
|
||||||
|
backfill=backfill,
|
||||||
|
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
|
||||||
|
tzinfo=timezone.utc
|
||||||
|
),
|
||||||
|
)
|
||||||
74
tests/test_export.py
Normal file
74
tests/test_export.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""CSV export.
|
||||||
|
|
||||||
|
The interesting part is not the formatting — it is that payroll data leaves
|
||||||
|
here for a spreadsheet, and a spreadsheet executes a cell that starts with
|
||||||
|
`=`, `+`, `-` or `@`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from ..export import COLUMNS, csv_safe, payouts_to_csv
|
||||||
|
from ..models import Payout, PayoutStatus
|
||||||
|
|
||||||
|
|
||||||
|
def make_payout(**overrides) -> Payout:
|
||||||
|
return Payout(
|
||||||
|
**{
|
||||||
|
"id": "p1",
|
||||||
|
"contract_id": "c1",
|
||||||
|
"period_index": 0,
|
||||||
|
"payday": "2026-01-15",
|
||||||
|
"status": PayoutStatus.paid,
|
||||||
|
"amount_msat": 800_000,
|
||||||
|
"amount": 800,
|
||||||
|
"currency": "sat",
|
||||||
|
"employee_id": "employee-account",
|
||||||
|
"employee_wallet": "wallet-employee",
|
||||||
|
"source_wallet": "wallet-treasury",
|
||||||
|
"created_at": datetime(2026, 1, 15, 9, 0, tzinfo=timezone.utc),
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_and_one_row():
|
||||||
|
out = payouts_to_csv([make_payout()])
|
||||||
|
lines = out.strip().splitlines()
|
||||||
|
assert lines[0] == ",".join(COLUMNS)
|
||||||
|
assert lines[1].startswith("2026-01-15,paid,1,800.0,sat,800,c1,0,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_amount_sat_comes_from_the_settled_msat_figure():
|
||||||
|
row = payouts_to_csv(
|
||||||
|
[make_payout(amount=800, currency="EUR", amount_msat=1_234_000)]
|
||||||
|
)
|
||||||
|
assert ",1234," in row # not re-derived from the EUR amount
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_row_exports_with_no_sat_amount():
|
||||||
|
out = payouts_to_csv(
|
||||||
|
[make_payout(status=PayoutStatus.failed, amount_msat=None, detail="no funds")]
|
||||||
|
)
|
||||||
|
assert "failed" in out and "no funds" in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- spreadsheet formula injection -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_formula_prefixes_are_neutralised():
|
||||||
|
for dangerous in ("=1+1", "+1", "-1", "@SUM(A1)", "\tx", "\rx"):
|
||||||
|
assert csv_safe(dangerous).startswith("'")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinary_values_are_untouched():
|
||||||
|
for benign in ("2026-01-15", "paid", "wallet-employee", "800.0", ""):
|
||||||
|
assert csv_safe(benign) == benign
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_hostile_detail_cannot_smuggle_a_formula_into_a_cell():
|
||||||
|
out = payouts_to_csv([make_payout(detail="=cmd|'/c calc'!A1")])
|
||||||
|
assert "'=cmd" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_becomes_an_empty_cell():
|
||||||
|
assert csv_safe(None) == ""
|
||||||
107
tests/test_lifecycle.py
Normal file
107
tests/test_lifecycle.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Contract lifecycle transitions.
|
||||||
|
|
||||||
|
Pure functions on the model, so no stubs needed. What matters here is which
|
||||||
|
moves are refused and — the one with money attached — what resuming does to
|
||||||
|
the paydays that fell while the contract was paused.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ..models import ContractStatus
|
||||||
|
from ..services import LifecycleError, cancel, pause, resume
|
||||||
|
from .conftest import make_contract
|
||||||
|
|
||||||
|
# --- pause -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_keeps_the_schedule_position():
|
||||||
|
contract = make_contract(periods_done=3)
|
||||||
|
pause(contract)
|
||||||
|
assert contract.status == ContractStatus.paused
|
||||||
|
assert contract.periods_done == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status",
|
||||||
|
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
|
||||||
|
)
|
||||||
|
def test_only_an_active_contract_can_be_paused(status):
|
||||||
|
with pytest.raises(LifecycleError):
|
||||||
|
pause(make_contract(status=status))
|
||||||
|
|
||||||
|
|
||||||
|
# --- resume ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_skips_the_paydays_missed_while_paused():
|
||||||
|
"""A pause is a decision not to pay. Resuming in April on a monthly
|
||||||
|
contract paused in January must not fire three transfers."""
|
||||||
|
contract = make_contract(
|
||||||
|
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
||||||
|
)
|
||||||
|
|
||||||
|
resume(contract, today=date(2026, 4, 10))
|
||||||
|
|
||||||
|
assert contract.status == ContractStatus.active
|
||||||
|
assert contract.periods_done == 3 # Jan/Feb/Mar written off
|
||||||
|
# 15 April is still ahead, so it is the next one paid.
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_with_catch_up_pays_the_missed_paydays():
|
||||||
|
contract = make_contract(
|
||||||
|
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
||||||
|
)
|
||||||
|
|
||||||
|
resume(contract, today=date(2026, 4, 10), catch_up=True)
|
||||||
|
|
||||||
|
assert contract.periods_done == 0 # the backlog is left for the scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_does_not_skip_a_payday_falling_today():
|
||||||
|
contract = make_contract(
|
||||||
|
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
||||||
|
)
|
||||||
|
|
||||||
|
resume(contract, today=date(2026, 1, 15))
|
||||||
|
|
||||||
|
assert contract.periods_done == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_resuming_past_the_end_completes_the_contract():
|
||||||
|
contract = make_contract(
|
||||||
|
start_date="2026-01-15",
|
||||||
|
total_periods=2,
|
||||||
|
periods_done=0,
|
||||||
|
status=ContractStatus.paused,
|
||||||
|
)
|
||||||
|
|
||||||
|
resume(contract, today=date(2027, 1, 1))
|
||||||
|
|
||||||
|
assert contract.status == ContractStatus.completed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status",
|
||||||
|
[ContractStatus.active, ContractStatus.cancelled, ContractStatus.completed],
|
||||||
|
)
|
||||||
|
def test_only_a_paused_contract_can_be_resumed(status):
|
||||||
|
with pytest.raises(LifecycleError):
|
||||||
|
resume(make_contract(status=status), today=date(2026, 6, 1))
|
||||||
|
|
||||||
|
|
||||||
|
# --- cancel ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [ContractStatus.active, ContractStatus.paused])
|
||||||
|
def test_cancel_from_a_live_status(status):
|
||||||
|
contract = make_contract(status=status)
|
||||||
|
cancel(contract)
|
||||||
|
assert contract.status == ContractStatus.cancelled
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [ContractStatus.cancelled, ContractStatus.completed])
|
||||||
|
def test_terminal_contracts_cannot_be_cancelled_again(status):
|
||||||
|
with pytest.raises(LifecycleError):
|
||||||
|
cancel(make_contract(status=status))
|
||||||
259
tests/test_payout.py
Normal file
259
tests/test_payout.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""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))
|
||||||
184
tests/test_schedule.py
Normal file
184
tests/test_schedule.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""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
Normal file
35
views.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""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
Normal file
409
views_api.py
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
"""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