Compare commits
No commits in common. "8a10c3caccde697b942ef017177c562434b73626" and "f253b51aa93eac441a8009bd2539b9812f6f56e7" have entirely different histories.
8a10c3cacc
...
f253b51aa9
21 changed files with 11 additions and 3186 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -8,9 +8,3 @@ node_modules/
|
||||||
.pytest_cache/
|
.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,13 +25,6 @@ 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
49
__init__.py
|
|
@ -1,49 +0,0 @@
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from .crud import db
|
|
||||||
from .views import payroll_generic_router
|
|
||||||
from .views_api import payroll_api_router, payroll_employee_router
|
|
||||||
|
|
||||||
payroll_static_files = [
|
|
||||||
{
|
|
||||||
"path": "/payroll/static",
|
|
||||||
"name": "payroll_static",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
payroll_ext: APIRouter = APIRouter(prefix="/payroll", tags=["payroll"])
|
|
||||||
payroll_ext.include_router(payroll_generic_router)
|
|
||||||
payroll_ext.include_router(payroll_api_router)
|
|
||||||
payroll_ext.include_router(payroll_employee_router)
|
|
||||||
|
|
||||||
scheduled_tasks: list[asyncio.Task] = []
|
|
||||||
|
|
||||||
|
|
||||||
def payroll_stop():
|
|
||||||
for task in scheduled_tasks:
|
|
||||||
try:
|
|
||||||
task.cancel()
|
|
||||||
except Exception as ex: # shutdown must not raise
|
|
||||||
logger.warning(ex)
|
|
||||||
|
|
||||||
|
|
||||||
def payroll_start():
|
|
||||||
from lnbits.tasks import create_permanent_unique_task
|
|
||||||
|
|
||||||
from .tasks import scheduler_loop
|
|
||||||
|
|
||||||
scheduled_tasks.append(
|
|
||||||
create_permanent_unique_task("ext_payroll_scheduler", scheduler_loop)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"db",
|
|
||||||
"payroll_ext",
|
|
||||||
"payroll_start",
|
|
||||||
"payroll_static_files",
|
|
||||||
"payroll_stop",
|
|
||||||
]
|
|
||||||
67
accounts.py
67
accounts.py
|
|
@ -1,67 +0,0 @@
|
||||||
"""Account directory — the read model behind the operator's user picker.
|
|
||||||
|
|
||||||
Payroll needs to answer one question core does not expose in a
|
|
||||||
payroll-shaped way: *which accounts exist, and which wallets could a
|
|
||||||
payout land in?* This module builds that list out of core's own crud, so
|
|
||||||
there is no second copy of the account table anywhere in this extension.
|
|
||||||
|
|
||||||
It reads accounts the operator does not own, which is exactly why every
|
|
||||||
caller of it is gated on `check_super_user` at the API boundary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from lnbits.core.crud import get_accounts, get_wallets
|
|
||||||
from lnbits.db import Filters
|
|
||||||
|
|
||||||
from .models import DirectoryUser, DirectoryWallet
|
|
||||||
|
|
||||||
# Core paginates accounts and defaults to 10 per page. A payroll picker
|
|
||||||
# wants the whole roster in one go; this cap keeps a pathological instance
|
|
||||||
# from building a five-figure dropdown, and the search box narrows anything
|
|
||||||
# larger server-side.
|
|
||||||
MAX_DIRECTORY_USERS = 500
|
|
||||||
|
|
||||||
|
|
||||||
async def list_directory_users(
|
|
||||||
search: str | None = None, limit: int = MAX_DIRECTORY_USERS
|
|
||||||
) -> list[DirectoryUser]:
|
|
||||||
"""Accounts with their (non-deleted) wallets, for the payout picker.
|
|
||||||
|
|
||||||
Accounts with no wallet are still returned: they are legitimate payroll
|
|
||||||
targets, they just need a wallet created before a contract can point at
|
|
||||||
one. Hiding them would make the operator wonder why a user they can see
|
|
||||||
in the admin UI is missing here.
|
|
||||||
"""
|
|
||||||
page = await get_accounts(
|
|
||||||
filters=Filters(search=search or None, limit=min(limit, MAX_DIRECTORY_USERS))
|
|
||||||
)
|
|
||||||
|
|
||||||
users: list[DirectoryUser] = []
|
|
||||||
for account in page.data:
|
|
||||||
wallets = await get_wallets(account.id)
|
|
||||||
users.append(
|
|
||||||
DirectoryUser(
|
|
||||||
id=account.id,
|
|
||||||
username=account.username,
|
|
||||||
email=account.email,
|
|
||||||
wallets=[
|
|
||||||
DirectoryWallet(id=w.id, name=w.name, balance_msat=w.balance_msat)
|
|
||||||
for w in wallets
|
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Readable ordering for a dropdown; core sorts by whatever the page
|
|
||||||
# query returned, which is account id.
|
|
||||||
users.sort(key=lambda u: (u.display_name or "").lower())
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
async def owns_wallet(user_id: str, wallet_id: str) -> bool:
|
|
||||||
"""Whether `wallet_id` belongs to `user_id`.
|
|
||||||
|
|
||||||
The guard that stops a payroll contract from being pointed at a wallet
|
|
||||||
belonging to somebody other than the selected employee — the id pair
|
|
||||||
arrives from a form, and nothing else in the write path re-checks it.
|
|
||||||
"""
|
|
||||||
wallets = await get_wallets(user_id)
|
|
||||||
return any(w.id == wallet_id for w in wallets)
|
|
||||||
75
crud.py
75
crud.py
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone
|
||||||
from lnbits.db import Database
|
from lnbits.db import Database
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
|
||||||
from .models import Contract, ContractStatus, CreateContract, Payout, PayoutStatus
|
from .models import Contract, ContractStatus, CreateContract
|
||||||
|
|
||||||
db = Database("ext_payroll")
|
db = Database("ext_payroll")
|
||||||
|
|
||||||
|
|
@ -87,76 +87,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
# Running payroll
|
|
||||||
|
|
||||||
## The scheduler
|
|
||||||
|
|
||||||
One permanent task (`ext_payroll_scheduler`), started with the extension.
|
|
||||||
It waits ~20s after boot, then every 5 minutes asks each **active**
|
|
||||||
contract whether it owes a payday.
|
|
||||||
|
|
||||||
Minutes, not seconds, on purpose: a payday is a calendar event, so paying
|
|
||||||
a few minutes into the day costs nothing, and a tight loop would only
|
|
||||||
multiply log noise while a contract cannot be funded. The post-boot pass
|
|
||||||
means an instance that was down over a payday catches up on restart rather
|
|
||||||
than waiting a full interval.
|
|
||||||
|
|
||||||
Days are **UTC**. An instance whose operators think in a far-eastern or
|
|
||||||
far-western timezone will see paydays land on what is locally the previous
|
|
||||||
or next day.
|
|
||||||
|
|
||||||
## How one period is paid
|
|
||||||
|
|
||||||
```
|
|
||||||
create_invoice(employee_wallet, amount, currency, internal=True)
|
|
||||||
│ ← this is also where a fiat contract is priced in sats
|
|
||||||
▼
|
|
||||||
invoice.amount (msat) ← canonical for this period, recorded as-is
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
pay_invoice(source_wallet, invoice.bolt11)
|
|
||||||
```
|
|
||||||
|
|
||||||
A plain internal LNbits wallet-to-wallet transfer. The invoice carries
|
|
||||||
`extra = {tag: "payroll", contract_id, period, payday}`, so a payroll
|
|
||||||
transfer is identifiable from the payments list on either wallet.
|
|
||||||
|
|
||||||
The sat amount is derived exactly once, by LNbits' own invoice pricing, and
|
|
||||||
everything downstream reuses that number. Re-deriving it from
|
|
||||||
`amount × rate` at any later point would drift — FX moves between quote and
|
|
||||||
settlement, and rounding accumulates over a year of paydays.
|
|
||||||
|
|
||||||
If a payout fails after the invoice exists, an unpaid internal invoice is
|
|
||||||
left on the employee's wallet. It was never settled and expires on its own;
|
|
||||||
that is the deliberate price of not pricing the period a second time just
|
|
||||||
to run a balance check.
|
|
||||||
|
|
||||||
## What a failure does
|
|
||||||
|
|
||||||
A failed period does **not** advance `periods_done`, so the next tick
|
|
||||||
retries the same payday. Later periods do not jump the queue: the backlog
|
|
||||||
halts at the first failure so paydays settle in order.
|
|
||||||
|
|
||||||
The common failure is an underfunded source wallet, and the log line says
|
|
||||||
so explicitly with both figures:
|
|
||||||
|
|
||||||
```
|
|
||||||
payroll: contract a1b2c3d4 period 3 failed: insufficient balance in source
|
|
||||||
wallet: 12000 sat available, 80000 sat required
|
|
||||||
```
|
|
||||||
|
|
||||||
Retries are bounded. After `MAX_PERIOD_ATTEMPTS` (5) failures on the *same*
|
|
||||||
period, the contract is **paused** and the operator has to act. Pausing
|
|
||||||
rather than abandoning the period is the point: a payday that cannot be
|
|
||||||
funded is a fact somebody needs to see, and silently dropping it is the one
|
|
||||||
outcome payroll must never produce. Because pausing does not advance the
|
|
||||||
position, resuming after topping up the source wallet retries that same
|
|
||||||
payday.
|
|
||||||
|
|
||||||
## The payout ledger
|
|
||||||
|
|
||||||
Every attempt — paid, skipped *and* failed — is written to
|
|
||||||
`payroll.payouts`. `GET /payroll/api/v1/payouts` returns them newest first,
|
|
||||||
filterable by `contract_id` and `status`. "Why did nobody get paid on the
|
|
||||||
1st" is the question the ledger exists to answer, which is why failures are
|
|
||||||
in it rather than only in the log.
|
|
||||||
|
|
||||||
Rows are self-describing: each one copies the terms in force at the time
|
|
||||||
(amount, currency, both wallet ids) rather than pointing at the contract,
|
|
||||||
so a payout still reads correctly after its contract is edited or deleted.
|
|
||||||
There is deliberately no foreign key to `contracts` — deleting a contract
|
|
||||||
must not take its history with it.
|
|
||||||
|
|
||||||
The attempt counter is derived by counting a period's prior failures in the
|
|
||||||
ledger, not from a column on the contract, so it survives a restart and the
|
|
||||||
rows that produced the number are right there to audit.
|
|
||||||
|
|
||||||
## Back-dated start dates
|
|
||||||
|
|
||||||
Creating a contract with a start date in the past is normally an
|
|
||||||
*anchoring* choice — "we pay on the 1st" — not a request for back-pay.
|
|
||||||
Periods whose payday fell before the contract row existed are therefore
|
|
||||||
**skipped**, and skipping still consumes the period, so the schedule stays
|
|
||||||
aligned to the anchor.
|
|
||||||
|
|
||||||
Tick `backfill` on the contract to pay them instead.
|
|
||||||
|
|
||||||
Either way a single tick will not fire more than `MAX_CATCH_UP_PERIODS`
|
|
||||||
(12) periods for one contract, so a mistyped start date cannot turn into a
|
|
||||||
hundred transfers.
|
|
||||||
|
|
||||||
## Double-payment guard
|
|
||||||
|
|
||||||
Each contract has an in-process `asyncio.Lock`, and `run_due_periods`
|
|
||||||
re-reads the contract row under it. The scheduler is a single task, so the
|
|
||||||
lock exists for the off-cycle paths that can land mid-tick.
|
|
||||||
|
|
||||||
This is per-process. Two LNbits processes sharing one database would not
|
|
||||||
be serialised by it — payroll assumes the single-writer deployment LNbits
|
|
||||||
itself assumes.
|
|
||||||
|
|
||||||
## Previewing a schedule
|
|
||||||
|
|
||||||
`POST /payroll/api/v1/schedule/preview` draws a calendar from loose terms
|
|
||||||
(`start_date`, `frequency`, `total_periods`) — no employee or wallet
|
|
||||||
required, because the schedule is usually the thing worth sanity-checking
|
|
||||||
first, and a mistyped start date is cheapest to fix before anything is
|
|
||||||
saved. `GET /payroll/api/v1/contracts/{id}/schedule` does the same for a
|
|
||||||
live contract, from its current position.
|
|
||||||
|
|
||||||
Both return `ends_on`, the contract's last payday. "12 monthly payments
|
|
||||||
from 15 Jan" is much easier to check against "ends 15 Dec" than against a
|
|
||||||
list of twelve dates.
|
|
||||||
|
|
||||||
## Paying off-cycle
|
|
||||||
|
|
||||||
`POST /payroll/api/v1/contracts/{id}/pay-now` settles the contract's next
|
|
||||||
period immediately, whatever the calendar says, and consumes it.
|
|
||||||
|
|
||||||
One endpoint covers both "run it now" (don't wait for the tick) and "pay it
|
|
||||||
early", because they are the same operation — they differ only in whether
|
|
||||||
today happens to be the payday. It bypasses the back-dated skip: that guard
|
|
||||||
exists to stop a *new* contract firing surprise back-pay, and an operator
|
|
||||||
explicitly asking to pay a period is not a surprise.
|
|
||||||
|
|
||||||
It is recorded in the ledger like any other payout, with the early payment
|
|
||||||
noted in `detail`, and the ledger row is returned even when the payout
|
|
||||||
failed — a manual payout that did not land is exactly when you want to know
|
|
||||||
why.
|
|
||||||
|
|
||||||
## Accounting export
|
|
||||||
|
|
||||||
`GET /payroll/api/v1/payouts.csv` (super user) exports the ledger, with
|
|
||||||
optional `contract_id`, `status`, `since` and `until`. `since`/`until` bound
|
|
||||||
the **payday**, not the row timestamp, so an accounting period contains the
|
|
||||||
paydays that belong to it even when one took three days of retries to
|
|
||||||
settle.
|
|
||||||
|
|
||||||
Every exported field is neutralised against spreadsheet formula injection —
|
|
||||||
a cell beginning `=`, `+`, `-` or `@` is prefixed with an apostrophe.
|
|
||||||
`detail` carries exception text and the memo carries operator input, and
|
|
||||||
neither is worth trusting to a colleague's Excel.
|
|
||||||
|
|
||||||
## What an employee can see
|
|
||||||
|
|
||||||
`/payroll/api/v1/my/payouts`, `/my/payouts.csv` and `/my/contracts` are
|
|
||||||
gated on a **wallet invoice key**, not on admin rights: whoever holds the
|
|
||||||
read key for a wallet may see what payroll has paid into that wallet, and
|
|
||||||
nothing else. They live on a separate router from the super-user API so
|
|
||||||
they cannot inherit — or accidentally shed — the wrong gate.
|
|
||||||
|
|
||||||
Scoped to the wallet rather than to the account on purpose: an invoice key
|
|
||||||
names exactly one wallet, so there is no lookup that could widen the result
|
|
||||||
to a sibling wallet the key does not cover.
|
|
||||||
|
|
||||||
Note this stays reachable even with `payroll` in `LNBITS_ADMIN_EXTENSIONS`.
|
|
||||||
That flag governs who sees the extension in the UI; the API routes are
|
|
||||||
mounted instance-wide, and these three are gated by the wallet key they
|
|
||||||
require rather than by UI visibility.
|
|
||||||
70
export.py
70
export.py
|
|
@ -1,70 +0,0 @@
|
||||||
"""CSV export of the payout ledger.
|
|
||||||
|
|
||||||
Payroll data leaves this extension for a spreadsheet, and a spreadsheet
|
|
||||||
treats a cell beginning with `=`, `+`, `-` or `@` as a formula. Every field
|
|
||||||
that reaches a cell is therefore neutralised on the way out — `detail`
|
|
||||||
carries exception text and the memo carries operator input, and neither is
|
|
||||||
worth trusting to a colleague's Excel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
|
|
||||||
from .models import Payout
|
|
||||||
|
|
||||||
COLUMNS = [
|
|
||||||
"payday",
|
|
||||||
"status",
|
|
||||||
"attempt",
|
|
||||||
"amount",
|
|
||||||
"currency",
|
|
||||||
"amount_sat",
|
|
||||||
"contract_id",
|
|
||||||
"period_index",
|
|
||||||
"employee_id",
|
|
||||||
"employee_wallet",
|
|
||||||
"source_wallet",
|
|
||||||
"payment_hash",
|
|
||||||
"detail",
|
|
||||||
"recorded_at",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Leading characters a spreadsheet reads as the start of a formula.
|
|
||||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
|
|
||||||
|
|
||||||
|
|
||||||
def csv_safe(value) -> str:
|
|
||||||
"""Render one value so a spreadsheet treats it as text, never a formula."""
|
|
||||||
text = "" if value is None else str(value)
|
|
||||||
if text.startswith(_FORMULA_PREFIXES):
|
|
||||||
return "'" + text
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def payouts_to_csv(payouts: list[Payout]) -> str:
|
|
||||||
buffer = io.StringIO()
|
|
||||||
writer = csv.writer(buffer)
|
|
||||||
writer.writerow(COLUMNS)
|
|
||||||
for p in payouts:
|
|
||||||
writer.writerow(
|
|
||||||
[
|
|
||||||
csv_safe(v)
|
|
||||||
for v in (
|
|
||||||
p.payday,
|
|
||||||
p.status.value,
|
|
||||||
p.attempt,
|
|
||||||
p.amount,
|
|
||||||
p.currency,
|
|
||||||
p.amount_sat,
|
|
||||||
p.contract_id,
|
|
||||||
p.period_index,
|
|
||||||
p.employee_id,
|
|
||||||
p.employee_wallet,
|
|
||||||
p.source_wallet,
|
|
||||||
p.payment_hash,
|
|
||||||
p.detail,
|
|
||||||
p.created_at.isoformat(),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return buffer.getvalue()
|
|
||||||
|
|
@ -16,7 +16,8 @@ schema.
|
||||||
async def m001_initial(db):
|
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(f"""
|
await db.execute(
|
||||||
|
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,
|
||||||
|
|
@ -36,55 +37,17 @@ 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("CREATE INDEX payroll.idx_contracts_status ON contracts (status);")
|
await db.execute(
|
||||||
|
"CREATE INDEX payroll.idx_contracts_status ON contracts (status);"
|
||||||
|
)
|
||||||
# Employee-facing lookups ("show me my payroll lines") filter by account.
|
# 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);"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
97
models.py
97
models.py
|
|
@ -56,8 +56,8 @@ class Frequency(str, Enum):
|
||||||
|
|
||||||
|
|
||||||
class ContractStatus(str, Enum):
|
class ContractStatus(str, Enum):
|
||||||
active = "active" # eligible for payout on every scheduler tick
|
active = "active" # eligible for payout on every scheduler tick
|
||||||
paused = "paused" # keeps its schedule position, pays nothing
|
paused = "paused" # keeps its schedule position, pays nothing
|
||||||
completed = "completed" # ran out of periods; terminal
|
completed = "completed" # ran out of periods; terminal
|
||||||
cancelled = "cancelled" # stopped by the operator; terminal
|
cancelled = "cancelled" # stopped by the operator; terminal
|
||||||
|
|
||||||
|
|
@ -93,7 +93,7 @@ class CreateContract(BaseModel):
|
||||||
total_periods: int | None = None
|
total_periods: int | None = None
|
||||||
|
|
||||||
label: str = "" # display name for the line, e.g. "Alice — dev retainer"
|
label: str = "" # display name for the line, e.g. "Alice — dev retainer"
|
||||||
memo: str = "" # payment memo the employee sees; falls back to `label`
|
memo: str = "" # payment memo the employee sees; falls back to `label`
|
||||||
|
|
||||||
# Periods whose payday already passed before the contract existed. Off by
|
# Periods whose payday already passed before the contract existed. Off by
|
||||||
# default: creating a contract with a back-dated start is normally an
|
# default: creating a contract with a back-dated start is normally an
|
||||||
|
|
@ -191,94 +191,3 @@ 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
598
services.py
|
|
@ -1,598 +0,0 @@
|
||||||
"""Payroll scheduling and payout.
|
|
||||||
|
|
||||||
Two separable things live here, and the split is deliberate:
|
|
||||||
|
|
||||||
* **Schedule math** (`occurrence_on`, `due_period_indices`) is pure — no
|
|
||||||
DB, no wallets, no clock of its own. Paydays are the part of payroll that
|
|
||||||
is easy to get subtly wrong and expensive to get wrong in production, so
|
|
||||||
it is kept testable in isolation.
|
|
||||||
* **Payout** (`run_due_periods`) is the effectful half: it turns a due
|
|
||||||
period into an actual wallet-to-wallet transfer.
|
|
||||||
|
|
||||||
Everything is anchored on `start_date`. The *n*-th payday is a function of
|
|
||||||
the start date and `n` alone — never of the previous payday, and never of
|
|
||||||
when the scheduler happened to run. That is what stops a missed tick, a
|
|
||||||
restart, or a short month from shifting the whole remaining schedule.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from calendar import monthrange
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from lnbits.core.crud import get_wallet
|
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from . import crud
|
|
||||||
from .models import (
|
|
||||||
TERMINAL_STATUSES,
|
|
||||||
Contract,
|
|
||||||
ContractStatus,
|
|
||||||
Frequency,
|
|
||||||
Payout,
|
|
||||||
PayoutStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Frequencies that are an exact number of days: no calendar involved, so no
|
|
||||||
# clamping is possible or needed.
|
|
||||||
_DAY_STEP = {
|
|
||||||
Frequency.daily: 1,
|
|
||||||
Frequency.weekly: 7,
|
|
||||||
Frequency.biweekly: 14,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Frequencies that step whole months and therefore clamp into short months.
|
|
||||||
_MONTH_STEP = {
|
|
||||||
Frequency.monthly: 1,
|
|
||||||
Frequency.quarterly: 3,
|
|
||||||
Frequency.yearly: 12,
|
|
||||||
}
|
|
||||||
|
|
||||||
# A single tick will not fire more than this many periods for one contract.
|
|
||||||
# Protects against a mistyped start date turning into a hundred transfers.
|
|
||||||
MAX_CATCH_UP_PERIODS = 12
|
|
||||||
|
|
||||||
# How many times one period may fail before the contract is paused for the
|
|
||||||
# operator to look at. Retrying an underfunded wallet forever is not
|
|
||||||
# resilience, it is a log the operator learns to ignore — and a missed
|
|
||||||
# payday deserves to be visible in the UI, not buried in journalctl.
|
|
||||||
MAX_PERIOD_ATTEMPTS = 5
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schedule math (pure)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def add_months(anchor: date, months: int) -> date:
|
|
||||||
"""`anchor` shifted by `months`, clamped to the target month's last day.
|
|
||||||
|
|
||||||
31 Jan + 1 month is 28 Feb (29 in a leap year), because 31 Feb does not
|
|
||||||
exist. Note this is applied to the *anchor*, not to a previous result:
|
|
||||||
31 Jan + 2 months is 31 Mar, not 28 Mar. Payroll anchored on month-end
|
|
||||||
should keep paying on month-end.
|
|
||||||
"""
|
|
||||||
total = anchor.year * 12 + (anchor.month - 1) + months
|
|
||||||
year, month_index = divmod(total, 12)
|
|
||||||
month = month_index + 1
|
|
||||||
return date(year, month, min(anchor.day, monthrange(year, month)[1]))
|
|
||||||
|
|
||||||
|
|
||||||
def occurrence_on(start: date, frequency: Frequency, index: int) -> date:
|
|
||||||
"""The payday for period `index`, counting the start date as period 0."""
|
|
||||||
if frequency in _DAY_STEP:
|
|
||||||
return start + timedelta(days=_DAY_STEP[frequency] * index)
|
|
||||||
return add_months(start, _MONTH_STEP[frequency] * index)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_start_date(contract: Contract) -> date:
|
|
||||||
return datetime.strptime(contract.start_date, "%Y-%m-%d").date()
|
|
||||||
|
|
||||||
|
|
||||||
def next_payday(contract: Contract) -> date | None:
|
|
||||||
"""When this contract pays next, or None if it has no periods left."""
|
|
||||||
remaining = contract.periods_remaining
|
|
||||||
if remaining is not None and remaining <= 0:
|
|
||||||
return None
|
|
||||||
return occurrence_on(
|
|
||||||
parse_start_date(contract), contract.frequency, contract.periods_done
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def paydays_from(
|
|
||||||
start: date,
|
|
||||||
frequency: Frequency,
|
|
||||||
first_index: int,
|
|
||||||
count: int,
|
|
||||||
total_periods: int | None = None,
|
|
||||||
) -> list[date]:
|
|
||||||
"""`count` paydays starting at period `first_index`, truncated by any cap.
|
|
||||||
|
|
||||||
Takes loose parameters rather than a Contract so the operator can preview
|
|
||||||
a schedule *before* the contract exists — which is the point at which a
|
|
||||||
mistyped start date or the wrong frequency is cheap to fix.
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
occurrence_on(start, frequency, i)
|
|
||||||
for i in range(first_index, first_index + count)
|
|
||||||
if total_periods is None or i < total_periods
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def upcoming_paydays(contract: Contract, count: int) -> list[date]:
|
|
||||||
"""The next `count` paydays for a live contract, from its position."""
|
|
||||||
return paydays_from(
|
|
||||||
parse_start_date(contract),
|
|
||||||
contract.frequency,
|
|
||||||
contract.periods_done,
|
|
||||||
count,
|
|
||||||
contract.total_periods,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def final_payday(
|
|
||||||
start: date, frequency: Frequency, total_periods: int | None
|
|
||||||
) -> date | None:
|
|
||||||
"""When an open-ended contract would end — None, by definition."""
|
|
||||||
if total_periods is None:
|
|
||||||
return None
|
|
||||||
return occurrence_on(start, frequency, total_periods - 1)
|
|
||||||
|
|
||||||
|
|
||||||
def due_period_indices(contract: Contract, today: date) -> list[int]:
|
|
||||||
"""Every period index that is payable as of `today`, in order.
|
|
||||||
|
|
||||||
Normally this is zero or one entry. It is a list because a contract can
|
|
||||||
legitimately have a backlog: a back-dated start date, or an instance
|
|
||||||
that was down over a payday. Returning them all lets the caller decide
|
|
||||||
what to do with each — `run_due_periods` pays or skips them one at a
|
|
||||||
time rather than collapsing a backlog into a single surprise transfer.
|
|
||||||
"""
|
|
||||||
if contract.status != ContractStatus.active:
|
|
||||||
return []
|
|
||||||
|
|
||||||
start = parse_start_date(contract)
|
|
||||||
cap = contract.total_periods
|
|
||||||
indices: list[int] = []
|
|
||||||
index = contract.periods_done
|
|
||||||
while cap is None or index < cap:
|
|
||||||
if occurrence_on(start, contract.frequency, index) > today:
|
|
||||||
break
|
|
||||||
indices.append(index)
|
|
||||||
index += 1
|
|
||||||
# A daily contract left unpaid for years would otherwise build an
|
|
||||||
# unbounded list. Anything beyond this is an operator problem, not
|
|
||||||
# something to silently drain in one tick.
|
|
||||||
if len(indices) >= MAX_CATCH_UP_PERIODS:
|
|
||||||
break
|
|
||||||
return indices
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Payout (effectful)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PeriodOutcome:
|
|
||||||
"""What happened to one period. `amount_msat` is the canonical settled
|
|
||||||
figure, taken from the invoice LNbits actually priced — never
|
|
||||||
recomputed from amount x rate."""
|
|
||||||
|
|
||||||
index: int
|
|
||||||
payday: date
|
|
||||||
status: PayoutStatus
|
|
||||||
detail: str = ""
|
|
||||||
amount_msat: int | None = None
|
|
||||||
payment_hash: str | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def consumed(self) -> bool:
|
|
||||||
"""Whether this period should advance the contract's position.
|
|
||||||
|
|
||||||
Paid and skipped both consume the period. Failed does not — that is
|
|
||||||
what makes the next tick retry the same payday rather than dropping
|
|
||||||
it.
|
|
||||||
"""
|
|
||||||
return self.status in (PayoutStatus.paid, PayoutStatus.skipped)
|
|
||||||
|
|
||||||
|
|
||||||
# One lock per contract id. The scheduler is a single task, but an operator
|
|
||||||
# can trigger an off-cycle payout at any moment; without this, a manual run
|
|
||||||
# landing mid-tick could pay the same period twice. Double-paying is the
|
|
||||||
# worst thing this extension can do, so the guard is cheap insurance.
|
|
||||||
_contract_locks: dict[str, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _lock_for(contract_id: str) -> asyncio.Lock:
|
|
||||||
lock = _contract_locks.get(contract_id)
|
|
||||||
if lock is None:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
_contract_locks[contract_id] = lock
|
|
||||||
return lock
|
|
||||||
|
|
||||||
|
|
||||||
def payout_memo(contract: Contract, payday: date) -> str:
|
|
||||||
label = contract.memo or contract.label or "Payroll"
|
|
||||||
return f"{label} — {payday.isoformat()}"
|
|
||||||
|
|
||||||
|
|
||||||
async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
|
||||||
"""Move one period's money from the source wallet to the employee wallet.
|
|
||||||
|
|
||||||
An internal invoice on the destination wallet, paid from the source
|
|
||||||
wallet — the standard LNbits wallet-to-wallet transfer. Creating the
|
|
||||||
invoice is also what prices a fiat contract in sats, so the amount is
|
|
||||||
converted exactly once, here, and the result is what gets recorded.
|
|
||||||
|
|
||||||
A failure after the invoice exists leaves an unpaid internal invoice on
|
|
||||||
the employee's wallet. That is harmless — it expires on its own and was
|
|
||||||
never settled — and it is the price of not pre-computing the sat amount
|
|
||||||
a second time just to run a balance check.
|
|
||||||
"""
|
|
||||||
memo = payout_memo(contract, payday)
|
|
||||||
extra = {
|
|
||||||
"tag": "payroll",
|
|
||||||
"contract_id": contract.id,
|
|
||||||
"period": index,
|
|
||||||
"payday": payday.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
invoice = await create_invoice(
|
|
||||||
wallet_id=contract.employee_wallet,
|
|
||||||
amount=contract.amount,
|
|
||||||
currency=contract.currency,
|
|
||||||
memo=memo,
|
|
||||||
internal=True,
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
# Broad: pricing, wallet limits and funding-source errors all surface
|
|
||||||
# here, and every one of them is a failed period rather than a crash.
|
|
||||||
except Exception as exc:
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.failed,
|
|
||||||
detail=f"could not raise invoice: {exc}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# invoice.amount is msat and is now the canonical figure for this period.
|
|
||||||
amount_msat = invoice.amount
|
|
||||||
|
|
||||||
source = await get_wallet(contract.source_wallet)
|
|
||||||
if not source:
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.failed,
|
|
||||||
detail="source wallet not found",
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
)
|
|
||||||
if source.balance_msat < amount_msat:
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.failed,
|
|
||||||
detail=(
|
|
||||||
f"insufficient balance in source wallet: "
|
|
||||||
f"{source.balance_msat // 1000} sat available, "
|
|
||||||
f"{amount_msat // 1000} sat required"
|
|
||||||
),
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await pay_invoice(
|
|
||||||
wallet_id=contract.source_wallet,
|
|
||||||
payment_request=invoice.bolt11,
|
|
||||||
description=memo,
|
|
||||||
tag="payroll",
|
|
||||||
extra=extra,
|
|
||||||
)
|
|
||||||
except Exception as exc: # a failed payment is an expected outcome
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.failed,
|
|
||||||
detail=f"payment failed: {exc}",
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
payment_hash=invoice.payment_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.paid,
|
|
||||||
amount_msat=amount_msat,
|
|
||||||
payment_hash=invoice.payment_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def run_due_periods(
|
|
||||||
contract: Contract, today: date | None = None
|
|
||||||
) -> list[Payout]:
|
|
||||||
"""Settle every period this contract owes as of `today`.
|
|
||||||
|
|
||||||
Every attempt — paid, skipped or failed — is written to the ledger, and
|
|
||||||
the ledger rows are what this returns.
|
|
||||||
|
|
||||||
Stops at the first failure so a backlog cannot pay periods out of order:
|
|
||||||
if period 3 could not be funded, period 4 waits for it. Both will be
|
|
||||||
retried on the next tick, because a failed period does not advance the
|
|
||||||
contract's position.
|
|
||||||
"""
|
|
||||||
today = today or datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
async with _lock_for(contract.id):
|
|
||||||
# Re-read under the lock: a manual run may have moved the position
|
|
||||||
# since the caller loaded this row.
|
|
||||||
fresh = await crud.get_contract(contract.id)
|
|
||||||
if not fresh:
|
|
||||||
return []
|
|
||||||
contract = fresh
|
|
||||||
|
|
||||||
payouts: list[Payout] = []
|
|
||||||
for index in due_period_indices(contract, today):
|
|
||||||
payday = occurrence_on(
|
|
||||||
parse_start_date(contract), contract.frequency, index
|
|
||||||
)
|
|
||||||
outcome = await _settle(contract, index, payday)
|
|
||||||
payout = await _record(contract, outcome)
|
|
||||||
payouts.append(payout)
|
|
||||||
|
|
||||||
if not outcome.consumed:
|
|
||||||
_maybe_pause_after_repeated_failure(contract, payout)
|
|
||||||
break
|
|
||||||
contract.periods_done = index + 1
|
|
||||||
|
|
||||||
if payouts:
|
|
||||||
_maybe_complete(contract)
|
|
||||||
await crud.update_contract(contract)
|
|
||||||
|
|
||||||
return payouts
|
|
||||||
|
|
||||||
|
|
||||||
async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout:
|
|
||||||
"""Write one attempt to the ledger.
|
|
||||||
|
|
||||||
The attempt number is derived by counting this period's prior failures
|
|
||||||
in the ledger itself rather than from a counter on the contract, so it
|
|
||||||
survives a restart and the rows that produced it are right there to
|
|
||||||
audit. A paid or skipped period is never retried, so it is always
|
|
||||||
attempt 1 as far as that outcome is concerned.
|
|
||||||
"""
|
|
||||||
attempt = 1
|
|
||||||
if outcome.status == PayoutStatus.failed:
|
|
||||||
attempt = await crud.count_period_failures(contract.id, outcome.index) + 1
|
|
||||||
|
|
||||||
return await crud.create_payout(
|
|
||||||
Payout(
|
|
||||||
id=urlsafe_short_hash()[:12],
|
|
||||||
contract_id=contract.id,
|
|
||||||
period_index=outcome.index,
|
|
||||||
payday=outcome.payday.isoformat(),
|
|
||||||
status=PayoutStatus(outcome.status),
|
|
||||||
attempt=attempt,
|
|
||||||
amount_msat=outcome.amount_msat,
|
|
||||||
# Terms in force at the time — a ledger row has to still read
|
|
||||||
# correctly after the contract is edited or deleted.
|
|
||||||
amount=contract.amount,
|
|
||||||
currency=contract.currency,
|
|
||||||
employee_id=contract.employee_id,
|
|
||||||
employee_wallet=contract.employee_wallet,
|
|
||||||
source_wallet=contract.source_wallet,
|
|
||||||
payment_hash=outcome.payment_hash,
|
|
||||||
detail=outcome.detail,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_pause_after_repeated_failure(contract: Contract, payout: Payout) -> None:
|
|
||||||
"""Give up retrying a period and hand it to the operator.
|
|
||||||
|
|
||||||
The contract is paused rather than the period abandoned: a payday that
|
|
||||||
cannot be funded is a fact somebody needs to act on, and silently
|
|
||||||
dropping it is the one outcome payroll must never produce. Resuming
|
|
||||||
after fixing the funding retries the same period, because pausing did
|
|
||||||
not advance the position.
|
|
||||||
"""
|
|
||||||
if payout.attempt < MAX_PERIOD_ATTEMPTS:
|
|
||||||
return
|
|
||||||
contract.status = ContractStatus.paused
|
|
||||||
logger.error(
|
|
||||||
f"payroll: contract {contract.id} paused after {payout.attempt} failed "
|
|
||||||
f"attempts at period {payout.period_index} ({payout.payday}): "
|
|
||||||
f"{payout.detail}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _settle(contract: Contract, index: int, payday: date) -> PeriodOutcome:
|
|
||||||
"""Pay a period, or skip it as back-dated.
|
|
||||||
|
|
||||||
A contract created with a start date in the past is normally an
|
|
||||||
*anchoring* choice — "we pay on the 1st" — not a request for back-pay.
|
|
||||||
Periods whose payday fell before the contract existed are therefore
|
|
||||||
skipped unless the operator explicitly asked to backfill, which keeps a
|
|
||||||
new contract from firing months of transfers on its first tick.
|
|
||||||
"""
|
|
||||||
if not contract.backfill and payday < contract.created_at.date():
|
|
||||||
logger.info(
|
|
||||||
f"payroll: contract {contract.id} period {index} ({payday}) "
|
|
||||||
"predates the contract; skipping (backfill is off)"
|
|
||||||
)
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=PayoutStatus.skipped,
|
|
||||||
detail="payday predates the contract and backfill is off",
|
|
||||||
)
|
|
||||||
|
|
||||||
outcome = await pay_period(contract, index, payday)
|
|
||||||
if outcome.status == PayoutStatus.paid:
|
|
||||||
logger.success(
|
|
||||||
f"payroll: contract {contract.id} period {index} paid "
|
|
||||||
f"{(outcome.amount_msat or 0) // 1000} sat to "
|
|
||||||
f"{contract.employee_wallet}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"payroll: contract {contract.id} period {index} "
|
|
||||||
f"{outcome.status.value}: {outcome.detail}"
|
|
||||||
)
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
|
|
||||||
def _maybe_complete(contract: Contract) -> None:
|
|
||||||
# Only an active contract completes: a period that just exhausted the
|
|
||||||
# retry budget has paused this one, and that has to stick.
|
|
||||||
if contract.status != ContractStatus.active:
|
|
||||||
return
|
|
||||||
if (
|
|
||||||
contract.total_periods is not None
|
|
||||||
and contract.periods_done >= contract.total_periods
|
|
||||||
):
|
|
||||||
contract.status = ContractStatus.completed
|
|
||||||
logger.info(
|
|
||||||
f"payroll: contract {contract.id} completed "
|
|
||||||
f"({contract.periods_done} periods)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def tick(today: date | None = None) -> None:
|
|
||||||
"""One scheduler pass over every active contract."""
|
|
||||||
contracts = await crud.get_contracts_by_status(ContractStatus.active)
|
|
||||||
for contract in contracts:
|
|
||||||
try:
|
|
||||||
await run_due_periods(contract, today)
|
|
||||||
except Exception as exc: # one bad row must not stop the rest of payroll
|
|
||||||
logger.error(f"payroll: contract {contract.id} tick failed: {exc}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class LifecycleError(ValueError):
|
|
||||||
"""An illegal status transition. Mapped to 409 at the API boundary."""
|
|
||||||
|
|
||||||
|
|
||||||
def fast_forward_index(contract: Contract, today: date) -> int:
|
|
||||||
"""The first period index whose payday is not already in the past."""
|
|
||||||
start = parse_start_date(contract)
|
|
||||||
cap = contract.total_periods
|
|
||||||
index = contract.periods_done
|
|
||||||
while (cap is None or index < cap) and occurrence_on(
|
|
||||||
start, contract.frequency, index
|
|
||||||
) < today:
|
|
||||||
index += 1
|
|
||||||
return index
|
|
||||||
|
|
||||||
|
|
||||||
def pause(contract: Contract) -> Contract:
|
|
||||||
if contract.status != ContractStatus.active:
|
|
||||||
raise LifecycleError(
|
|
||||||
f"Only an active contract can be paused (is {contract.status.value})."
|
|
||||||
)
|
|
||||||
contract.status = ContractStatus.paused
|
|
||||||
return contract
|
|
||||||
|
|
||||||
|
|
||||||
def resume(
|
|
||||||
contract: Contract, today: date | None = None, catch_up: bool = False
|
|
||||||
) -> Contract:
|
|
||||||
"""Put a paused contract back to work.
|
|
||||||
|
|
||||||
By default the paydays that fell during the pause are *not* paid: a
|
|
||||||
pause is a decision not to pay them, and resuming into a surprise
|
|
||||||
multi-period transfer is the opposite of what an operator asking to
|
|
||||||
resume expects. The contract's position is fast-forwarded to the next
|
|
||||||
payday on or after today instead.
|
|
||||||
|
|
||||||
`catch_up=True` opts into paying them, for the case where the pause was
|
|
||||||
an operational hold rather than a decision about the money.
|
|
||||||
"""
|
|
||||||
if contract.status != ContractStatus.paused:
|
|
||||||
raise LifecycleError(
|
|
||||||
f"Only a paused contract can be resumed (is {contract.status.value})."
|
|
||||||
)
|
|
||||||
|
|
||||||
contract.status = ContractStatus.active
|
|
||||||
if not catch_up:
|
|
||||||
today = today or datetime.now(timezone.utc).date()
|
|
||||||
skipped_to = fast_forward_index(contract, today)
|
|
||||||
if skipped_to != contract.periods_done:
|
|
||||||
logger.info(
|
|
||||||
f"payroll: contract {contract.id} resumed, skipping "
|
|
||||||
f"{skipped_to - contract.periods_done} payday(s) missed while paused"
|
|
||||||
)
|
|
||||||
contract.periods_done = skipped_to
|
|
||||||
_maybe_complete(contract)
|
|
||||||
return contract
|
|
||||||
|
|
||||||
|
|
||||||
def cancel(contract: Contract) -> Contract:
|
|
||||||
"""Stop a contract for good, keeping the row and its history.
|
|
||||||
|
|
||||||
This — not DELETE — is how a running payroll is stopped: deleting throws
|
|
||||||
away the schedule position and the record that the contract ever existed.
|
|
||||||
"""
|
|
||||||
if contract.status in TERMINAL_STATUSES:
|
|
||||||
raise LifecycleError(f"Contract is already {contract.status.value}.")
|
|
||||||
contract.status = ContractStatus.cancelled
|
|
||||||
return contract
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Off-cycle payout
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def pay_now(contract: Contract) -> Payout:
|
|
||||||
"""Settle the contract's next period immediately, whatever the calendar
|
|
||||||
says.
|
|
||||||
|
|
||||||
The operator's manual override: "run it now" when waiting five minutes
|
|
||||||
for the tick is not acceptable, and "pay it early" when a payday needs to
|
|
||||||
land before it is due. Both are the same operation — the next period
|
|
||||||
settles and is consumed — so there is one endpoint rather than two that
|
|
||||||
differ only in whether today happens to be the payday.
|
|
||||||
|
|
||||||
It calls `pay_period` directly rather than going through `_settle`,
|
|
||||||
because `_settle`'s back-dated skip exists to stop a *new* contract
|
|
||||||
firing surprise back-pay. An operator explicitly asking to pay a period
|
|
||||||
is not a surprise.
|
|
||||||
"""
|
|
||||||
async with _lock_for(contract.id):
|
|
||||||
fresh = await crud.get_contract(contract.id)
|
|
||||||
if not fresh:
|
|
||||||
raise LifecycleError("Contract not found.")
|
|
||||||
contract = fresh
|
|
||||||
|
|
||||||
if contract.status != ContractStatus.active:
|
|
||||||
raise LifecycleError(
|
|
||||||
f"Only an active contract can be paid (is {contract.status.value})."
|
|
||||||
)
|
|
||||||
remaining = contract.periods_remaining
|
|
||||||
if remaining is not None and remaining <= 0:
|
|
||||||
raise LifecycleError("Contract has no periods left to pay.")
|
|
||||||
|
|
||||||
index = contract.periods_done
|
|
||||||
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
|
|
||||||
today = datetime.now(timezone.utc).date()
|
|
||||||
|
|
||||||
outcome = await pay_period(contract, index, payday)
|
|
||||||
if payday > today and outcome.status == PayoutStatus.paid:
|
|
||||||
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"
|
|
||||||
|
|
||||||
payout = await _record(contract, outcome)
|
|
||||||
if outcome.consumed:
|
|
||||||
contract.periods_done = index + 1
|
|
||||||
_maybe_complete(contract)
|
|
||||||
else:
|
|
||||||
_maybe_pause_after_repeated_failure(contract, payout)
|
|
||||||
await crud.update_contract(contract)
|
|
||||||
return payout
|
|
||||||
|
|
@ -1,491 +0,0 @@
|
||||||
// Payroll operator console — LNbits page shell (Vue 3 + Quasar 2 UMD, no
|
|
||||||
// build step). Gotchas honored: no self-closing tags in the template,
|
|
||||||
// `${ }` interpolation so Jinja never sees a moustache, and `:style`
|
|
||||||
// bindings rather than <style> blocks for typography, which LNbits themes
|
|
||||||
// override with !important. See workspace CLAUDE.md.
|
|
||||||
|
|
||||||
const API = '/payroll/api/v1'
|
|
||||||
|
|
||||||
const FREQUENCIES = [
|
|
||||||
{value: 'daily', label: 'Daily'},
|
|
||||||
{value: 'weekly', label: 'Weekly'},
|
|
||||||
{value: 'biweekly', label: 'Every two weeks'},
|
|
||||||
{value: 'monthly', label: 'Monthly'},
|
|
||||||
{value: 'quarterly', label: 'Quarterly'},
|
|
||||||
{value: 'yearly', label: 'Yearly'}
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_COLOUR = {
|
|
||||||
active: 'positive',
|
|
||||||
paused: 'warning',
|
|
||||||
completed: 'grey',
|
|
||||||
cancelled: 'grey-7',
|
|
||||||
paid: 'positive',
|
|
||||||
skipped: 'grey',
|
|
||||||
failed: 'negative'
|
|
||||||
}
|
|
||||||
|
|
||||||
function today() {
|
|
||||||
return new Date().toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyContract() {
|
|
||||||
return {
|
|
||||||
employee_id: null,
|
|
||||||
employee_wallet: null,
|
|
||||||
source_wallet: null,
|
|
||||||
amount: null,
|
|
||||||
currency: 'sat',
|
|
||||||
frequency: 'monthly',
|
|
||||||
start_date: today(),
|
|
||||||
// The form models duration as a toggle plus a count, because
|
|
||||||
// "open-ended" and "12 payments" are the two things an operator
|
|
||||||
// actually says; the API takes total_periods = null for the former.
|
|
||||||
open_ended: false,
|
|
||||||
total_periods: 12,
|
|
||||||
label: '',
|
|
||||||
memo: '',
|
|
||||||
backfill: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.app = Vue.createApp({
|
|
||||||
el: '#vue',
|
|
||||||
mixins: [windowMixin],
|
|
||||||
delimiters: ['${', '}'],
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
tab: 'contracts',
|
|
||||||
|
|
||||||
contracts: [],
|
|
||||||
contractsLoading: false,
|
|
||||||
payouts: [],
|
|
||||||
payoutsLoading: false,
|
|
||||||
|
|
||||||
users: [],
|
|
||||||
currencyOptions: ['sat'],
|
|
||||||
currencyFilter: ['sat'],
|
|
||||||
frequencyOptions: FREQUENCIES,
|
|
||||||
|
|
||||||
contractDialog: {show: false, data: emptyContract(), saving: false},
|
|
||||||
// Live calendar of whatever is currently typed into the dialog. The
|
|
||||||
// cheapest moment to notice a wrong start date or frequency is before
|
|
||||||
// anything is saved.
|
|
||||||
preview: null,
|
|
||||||
editDialog: {show: false, id: null, data: {}},
|
|
||||||
scheduleDialog: {show: false, contract: null, preview: null},
|
|
||||||
|
|
||||||
ledgerFilter: {contract_id: null, status: null, since: '', until: ''},
|
|
||||||
|
|
||||||
contractsColumns: [
|
|
||||||
{
|
|
||||||
name: 'employee',
|
|
||||||
label: 'Employee',
|
|
||||||
align: 'left',
|
|
||||||
field: row => row.label || row.employee_username || row.employee_id
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'amount',
|
|
||||||
label: 'Amount',
|
|
||||||
align: 'right',
|
|
||||||
field: row => `${row.amount} ${row.currency}`
|
|
||||||
},
|
|
||||||
{name: 'frequency', label: 'Every', align: 'left', field: 'frequency'},
|
|
||||||
{name: 'next', label: 'Next payday', align: 'left', field: 'id'},
|
|
||||||
{name: 'progress', label: 'Paid', align: 'center', field: 'id'},
|
|
||||||
{name: 'status', label: 'Status', align: 'center', field: 'status'},
|
|
||||||
{name: 'actions', label: '', align: 'right', field: 'id'}
|
|
||||||
],
|
|
||||||
payoutsColumns: [
|
|
||||||
{name: 'payday', label: 'Payday', align: 'left', field: 'payday'},
|
|
||||||
{name: 'status', label: 'Status', align: 'center', field: 'status'},
|
|
||||||
{
|
|
||||||
name: 'amount',
|
|
||||||
label: 'Amount',
|
|
||||||
align: 'right',
|
|
||||||
field: row =>
|
|
||||||
row.amount_msat === null
|
|
||||||
? '—'
|
|
||||||
: `${Math.round(row.amount_msat / 1000)} sat`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'instruction',
|
|
||||||
label: 'Contract terms',
|
|
||||||
align: 'right',
|
|
||||||
field: row => `${row.amount} ${row.currency}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'contract',
|
|
||||||
label: 'Contract',
|
|
||||||
align: 'left',
|
|
||||||
field: 'contract_id'
|
|
||||||
},
|
|
||||||
{name: 'attempt', label: 'Try', align: 'center', field: 'attempt'},
|
|
||||||
{name: 'detail', label: 'Detail', align: 'left', field: 'detail'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
userOptions() {
|
|
||||||
return this.users.map(u => ({
|
|
||||||
label: u.username || u.email || u.id,
|
|
||||||
value: u.id,
|
|
||||||
caption: `${u.wallets.length} wallet(s)`
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
// Wallets belonging to the employee currently selected in the dialog.
|
|
||||||
// The API rejects a wallet that is not theirs, so narrowing the picker
|
|
||||||
// is about not offering the mistake in the first place.
|
|
||||||
employeeWalletOptions() {
|
|
||||||
const user = this.users.find(
|
|
||||||
u => u.id === this.contractDialog.data.employee_id
|
|
||||||
)
|
|
||||||
if (!user) return []
|
|
||||||
return user.wallets.map(w => ({
|
|
||||||
label: `${w.name} — ${Math.round(w.balance_msat / 1000)} sat`,
|
|
||||||
value: w.id
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
sourceWalletOptions() {
|
|
||||||
return (this.g.user.wallets || []).map(w => ({
|
|
||||||
label: w.name,
|
|
||||||
value: w.id
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
contractOptions() {
|
|
||||||
return this.contracts.map(c => ({
|
|
||||||
label: `${c.label || c.employee_username || c.id} (${c.id})`,
|
|
||||||
value: c.id
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
|
|
||||||
canSaveContract() {
|
|
||||||
const d = this.contractDialog.data
|
|
||||||
return !!(
|
|
||||||
d.employee_id &&
|
|
||||||
d.employee_wallet &&
|
|
||||||
d.source_wallet &&
|
|
||||||
d.amount > 0 &&
|
|
||||||
d.start_date
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
ledgerCsvHref() {
|
|
||||||
const f = this.ledgerFilter
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (f.contract_id) params.append('contract_id', f.contract_id)
|
|
||||||
if (f.status) params.append('status', f.status)
|
|
||||||
if (f.since) params.append('since', f.since)
|
|
||||||
if (f.until) params.append('until', f.until)
|
|
||||||
return `${API}/payouts.csv?${params.toString()}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
// Redraw the calendar whenever a term that shapes it changes.
|
|
||||||
'contractDialog.data.start_date': 'refreshPreview',
|
|
||||||
'contractDialog.data.frequency': 'refreshPreview',
|
|
||||||
'contractDialog.data.total_periods': 'refreshPreview',
|
|
||||||
'contractDialog.data.open_ended': 'refreshPreview'
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
statusColour(status) {
|
|
||||||
return STATUS_COLOUR[status] || 'grey'
|
|
||||||
},
|
|
||||||
|
|
||||||
_err(err, fallback) {
|
|
||||||
if (err && err.response) return LNbits.utils.notifyApiError(err)
|
|
||||||
Quasar.Notify.create({type: 'negative', message: fallback})
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- loading ----------------------------------------------------------
|
|
||||||
|
|
||||||
async getUsers() {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request('GET', `${API}/users`)
|
|
||||||
this.users = data
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not load the account directory')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCurrencies() {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request('GET', `${API}/currencies`)
|
|
||||||
this.currencyOptions = data
|
|
||||||
this.currencyFilter = data
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not load currencies')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
filterCurrencies(needle, update) {
|
|
||||||
update(() => {
|
|
||||||
const term = (needle || '').toLowerCase()
|
|
||||||
this.currencyFilter = this.currencyOptions.filter(c =>
|
|
||||||
c.toLowerCase().includes(term)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async getContracts() {
|
|
||||||
this.contractsLoading = true
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request('GET', `${API}/contracts`)
|
|
||||||
this.contracts = data
|
|
||||||
// Next payday is derived server-side; fetch it per row so the table
|
|
||||||
// never shows a date this page computed for itself.
|
|
||||||
await Promise.all(this.contracts.map(c => this.attachNextPayday(c)))
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not load contracts')
|
|
||||||
} finally {
|
|
||||||
this.contractsLoading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async attachNextPayday(contract) {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`${API}/contracts/${contract.id}/schedule?count=1`
|
|
||||||
)
|
|
||||||
contract.next_payday = data.paydays[0] || null
|
|
||||||
contract.ends_on = data.ends_on
|
|
||||||
} catch (err) {
|
|
||||||
contract.next_payday = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPayouts() {
|
|
||||||
this.payoutsLoading = true
|
|
||||||
const f = this.ledgerFilter
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
if (f.contract_id) params.append('contract_id', f.contract_id)
|
|
||||||
if (f.status) params.append('status', f.status)
|
|
||||||
if (f.since) params.append('since', f.since)
|
|
||||||
if (f.until) params.append('until', f.until)
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`${API}/payouts?${params.toString()}`
|
|
||||||
)
|
|
||||||
this.payouts = data
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not load the payout ledger')
|
|
||||||
} finally {
|
|
||||||
this.payoutsLoading = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- create / edit ----------------------------------------------------
|
|
||||||
|
|
||||||
openContractDialog() {
|
|
||||||
this.contractDialog.data = emptyContract()
|
|
||||||
this.preview = null
|
|
||||||
this.contractDialog.show = true
|
|
||||||
this.refreshPreview()
|
|
||||||
},
|
|
||||||
|
|
||||||
async refreshPreview() {
|
|
||||||
const d = this.contractDialog.data
|
|
||||||
if (!d.start_date || !/^\d{4}-\d{2}-\d{2}$/.test(d.start_date)) {
|
|
||||||
this.preview = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`${API}/schedule/preview`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
start_date: d.start_date,
|
|
||||||
frequency: d.frequency,
|
|
||||||
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
||||||
count: 6
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.preview = data
|
|
||||||
} catch (err) {
|
|
||||||
this.preview = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveContract() {
|
|
||||||
const d = this.contractDialog.data
|
|
||||||
this.contractDialog.saving = true
|
|
||||||
try {
|
|
||||||
await LNbits.api.request('POST', `${API}/contracts`, null, {
|
|
||||||
employee_id: d.employee_id,
|
|
||||||
employee_wallet: d.employee_wallet,
|
|
||||||
source_wallet: d.source_wallet,
|
|
||||||
amount: Number(d.amount),
|
|
||||||
currency: d.currency,
|
|
||||||
frequency: d.frequency,
|
|
||||||
start_date: d.start_date,
|
|
||||||
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
||||||
label: d.label,
|
|
||||||
memo: d.memo,
|
|
||||||
backfill: d.backfill
|
|
||||||
})
|
|
||||||
this.contractDialog.show = false
|
|
||||||
Quasar.Notify.create({type: 'positive', message: 'Payroll line created'})
|
|
||||||
this.getContracts()
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not create the payroll line')
|
|
||||||
} finally {
|
|
||||||
this.contractDialog.saving = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openEditDialog(contract) {
|
|
||||||
this.editDialog = {
|
|
||||||
show: true,
|
|
||||||
id: contract.id,
|
|
||||||
data: {
|
|
||||||
amount: contract.amount,
|
|
||||||
currency: contract.currency,
|
|
||||||
frequency: contract.frequency,
|
|
||||||
open_ended: contract.total_periods === null,
|
|
||||||
total_periods: contract.total_periods,
|
|
||||||
label: contract.label,
|
|
||||||
memo: contract.memo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveEdit() {
|
|
||||||
const d = this.editDialog.data
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'PUT',
|
|
||||||
`${API}/contracts/${this.editDialog.id}`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
amount: Number(d.amount),
|
|
||||||
currency: d.currency,
|
|
||||||
frequency: d.frequency,
|
|
||||||
total_periods: d.open_ended ? null : Number(d.total_periods),
|
|
||||||
label: d.label,
|
|
||||||
memo: d.memo
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.editDialog.show = false
|
|
||||||
Quasar.Notify.create({type: 'positive', message: 'Terms updated'})
|
|
||||||
this.getContracts()
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not update the contract')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- lifecycle --------------------------------------------------------
|
|
||||||
|
|
||||||
async transition(contract, action, params = '') {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`${API}/contracts/${contract.id}/${action}${params}`
|
|
||||||
)
|
|
||||||
this.getContracts()
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, `Could not ${action} the contract`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
resumeContract(contract) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'Resume this contract? The paydays missed while it was paused ' +
|
|
||||||
'are written off — they are not paid retroactively.'
|
|
||||||
)
|
|
||||||
.onOk(() => this.transition(contract, 'resume'))
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelContract(contract) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'Cancel this contract for good? It stops paying but keeps its ' +
|
|
||||||
'history. This cannot be undone.'
|
|
||||||
)
|
|
||||||
.onOk(() => this.transition(contract, 'cancel'))
|
|
||||||
},
|
|
||||||
|
|
||||||
payNow(contract) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
`Pay the next period of "${contract.label || contract.id}" now? ` +
|
|
||||||
'It is consumed even if its payday has not arrived yet.'
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'POST',
|
|
||||||
`${API}/contracts/${contract.id}/pay-now`
|
|
||||||
)
|
|
||||||
// A refused payout still returns a ledger row — surfacing its
|
|
||||||
// reason is the whole point of triggering one by hand.
|
|
||||||
Quasar.Notify.create({
|
|
||||||
type: data.status === 'paid' ? 'positive' : 'negative',
|
|
||||||
message:
|
|
||||||
data.status === 'paid'
|
|
||||||
? `Paid ${Math.round(data.amount_msat / 1000)} sat`
|
|
||||||
: `Payout ${data.status}: ${data.detail}`,
|
|
||||||
timeout: data.status === 'paid' ? 3000 : 8000
|
|
||||||
})
|
|
||||||
this.getContracts()
|
|
||||||
this.getPayouts()
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not pay now')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteContract(contract) {
|
|
||||||
LNbits.utils
|
|
||||||
.confirmDialog(
|
|
||||||
'Delete this contract row outright? Its schedule position goes ' +
|
|
||||||
'with it. To stop a running payroll, cancel it instead — that ' +
|
|
||||||
'keeps the record.'
|
|
||||||
)
|
|
||||||
.onOk(async () => {
|
|
||||||
try {
|
|
||||||
await LNbits.api.request('DELETE', `${API}/contracts/${contract.id}`)
|
|
||||||
this.getContracts()
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not delete the contract')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async showSchedule(contract) {
|
|
||||||
this.scheduleDialog = {show: true, contract, preview: null}
|
|
||||||
try {
|
|
||||||
const {data} = await LNbits.api.request(
|
|
||||||
'GET',
|
|
||||||
`${API}/contracts/${contract.id}/schedule?count=24`
|
|
||||||
)
|
|
||||||
this.scheduleDialog.preview = data
|
|
||||||
} catch (err) {
|
|
||||||
this._err(err, 'Could not load the schedule')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showLedgerFor(contract) {
|
|
||||||
this.ledgerFilter.contract_id = contract.id
|
|
||||||
this.tab = 'ledger'
|
|
||||||
this.getPayouts()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
this.getUsers()
|
|
||||||
this.getCurrencies()
|
|
||||||
this.getContracts()
|
|
||||||
this.getPayouts()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
36
tasks.py
36
tasks.py
|
|
@ -1,36 +0,0 @@
|
||||||
"""The payroll scheduler.
|
|
||||||
|
|
||||||
One permanent task that wakes up, asks every active contract whether it
|
|
||||||
owes a payday, and settles the ones that do. Deliberately dumb: all the
|
|
||||||
decisions live in services.py, and this file only owns the clock.
|
|
||||||
|
|
||||||
The tick interval is minutes rather than seconds because a payday is a
|
|
||||||
calendar event — the cost of paying an hour into the day is nil, and a
|
|
||||||
tight loop would only multiply log noise when a contract cannot be funded.
|
|
||||||
A pass also runs shortly after startup so an instance that was down over a
|
|
||||||
payday catches up without waiting a full interval.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from . import services
|
|
||||||
|
|
||||||
# How often to look for due paydays.
|
|
||||||
TICK_SECONDS = 300
|
|
||||||
|
|
||||||
# Let the funding source, DB and extension registry settle before the first
|
|
||||||
# pass — the first tick can move money, so it should not race startup.
|
|
||||||
STARTUP_DELAY_SECONDS = 20
|
|
||||||
|
|
||||||
|
|
||||||
async def scheduler_loop():
|
|
||||||
await asyncio.sleep(STARTUP_DELAY_SECONDS)
|
|
||||||
logger.info("payroll: scheduler started")
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await services.tick()
|
|
||||||
except Exception as exc: # the loop must outlive any single tick
|
|
||||||
logger.error(f"payroll: scheduler tick failed: {exc}")
|
|
||||||
await asyncio.sleep(TICK_SECONDS)
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
{% from "macros.jinja" import window_vars with context %}
|
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
{{ window_vars(user) }}
|
|
||||||
<script src="{{ static_url_for('payroll/static', path='js/index.js') }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block page %}
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12">
|
|
||||||
|
|
||||||
<div class="row items-center q-mb-md">
|
|
||||||
<div class="col">
|
|
||||||
<h4 class="q-my-none">Payroll</h4>
|
|
||||||
<p class="text-caption q-my-none" :style="{opacity: 0.7}">
|
|
||||||
Recurring payouts from a wallet you control into an employee's wallet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-btn unelevated color="primary" icon="add" label="New payroll line"
|
|
||||||
@click="openContractDialog"></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-card>
|
|
||||||
<q-tabs v-model="tab" align="left"
|
|
||||||
active-color="primary" indicator-color="primary" class="text-grey">
|
|
||||||
<q-tab name="contracts" icon="event_repeat" label="Contracts"></q-tab>
|
|
||||||
<q-tab name="ledger" icon="receipt_long" label="Payout ledger"></q-tab>
|
|
||||||
</q-tabs>
|
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
<q-tab-panels v-model="tab" animated>
|
|
||||||
|
|
||||||
<!-- ------------------------------------------------ contracts -->
|
|
||||||
<q-tab-panel name="contracts">
|
|
||||||
<q-table flat dense :rows="contracts" :columns="contractsColumns"
|
|
||||||
row-key="id" :loading="contractsLoading"
|
|
||||||
:pagination="{rowsPerPage: 25}">
|
|
||||||
|
|
||||||
<template v-slot:body-cell-next="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<span v-if="props.row.next_payday">
|
|
||||||
${ props.row.next_payday }
|
|
||||||
</span>
|
|
||||||
<span v-else :style="{opacity: 0.5}">—</span>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-progress="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
${ props.row.periods_done }<span :style="{opacity: 0.5}">
|
|
||||||
/ ${ props.row.total_periods === null ? '∞' : props.row.total_periods }
|
|
||||||
</span>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-status="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-chip dense square :color="statusColour(props.row.status)"
|
|
||||||
text-color="white" :label="props.row.status"></q-chip>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-actions="props">
|
|
||||||
<q-td :props="props" class="q-gutter-xs">
|
|
||||||
<q-btn flat dense round icon="event" size="sm"
|
|
||||||
@click="showSchedule(props.row)">
|
|
||||||
<q-tooltip>Upcoming paydays</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round icon="receipt_long" size="sm"
|
|
||||||
@click="showLedgerFor(props.row)">
|
|
||||||
<q-tooltip>Payout history</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round icon="edit" size="sm"
|
|
||||||
@click="openEditDialog(props.row)">
|
|
||||||
<q-tooltip>Edit terms</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="props.row.status === 'active'"
|
|
||||||
flat dense round icon="bolt" size="sm" color="primary"
|
|
||||||
@click="payNow(props.row)">
|
|
||||||
<q-tooltip>Pay the next period now</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="props.row.status === 'active'"
|
|
||||||
flat dense round icon="pause" size="sm"
|
|
||||||
@click="transition(props.row, 'pause')">
|
|
||||||
<q-tooltip>Pause</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="props.row.status === 'paused'"
|
|
||||||
flat dense round icon="play_arrow" size="sm" color="positive"
|
|
||||||
@click="resumeContract(props.row)">
|
|
||||||
<q-tooltip>Resume</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn v-if="props.row.status === 'active' || props.row.status === 'paused'"
|
|
||||||
flat dense round icon="block" size="sm" color="warning"
|
|
||||||
@click="cancelContract(props.row)">
|
|
||||||
<q-tooltip>Cancel for good</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
<q-btn flat dense round icon="delete" size="sm" color="negative"
|
|
||||||
@click="deleteContract(props.row)">
|
|
||||||
<q-tooltip>Delete the row and its schedule position</q-tooltip>
|
|
||||||
</q-btn>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:no-data>
|
|
||||||
<div class="full-width row justify-center q-pa-lg"
|
|
||||||
:style="{opacity: 0.6}">
|
|
||||||
No payroll lines yet.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
<!-- --------------------------------------------------- ledger -->
|
|
||||||
<q-tab-panel name="ledger">
|
|
||||||
<div class="row q-col-gutter-sm q-mb-md items-end">
|
|
||||||
<div class="col-12 col-md-4">
|
|
||||||
<q-select outlined dense clearable v-model="ledgerFilter.contract_id"
|
|
||||||
:options="contractOptions" emit-value map-options
|
|
||||||
label="Contract" @update:model-value="getPayouts"></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-2">
|
|
||||||
<q-select outlined dense clearable v-model="ledgerFilter.status"
|
|
||||||
:options="['paid', 'failed', 'skipped']"
|
|
||||||
label="Status" @update:model-value="getPayouts"></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-2">
|
|
||||||
<q-input outlined dense v-model="ledgerFilter.since"
|
|
||||||
mask="####-##-##" label="Payday from" @blur="getPayouts"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-2">
|
|
||||||
<q-input outlined dense v-model="ledgerFilter.until"
|
|
||||||
mask="####-##-##" label="Payday to" @blur="getPayouts"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-2">
|
|
||||||
<q-btn outline color="primary" icon="download" label="CSV"
|
|
||||||
type="a" :href="ledgerCsvHref"></q-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-table flat dense :rows="payouts" :columns="payoutsColumns"
|
|
||||||
row-key="id" :loading="payoutsLoading"
|
|
||||||
:pagination="{rowsPerPage: 25}">
|
|
||||||
|
|
||||||
<template v-slot:body-cell-status="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-chip dense square :color="statusColour(props.row.status)"
|
|
||||||
text-color="white" :label="props.row.status"></q-chip>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:body-cell-detail="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<span :style="{opacity: 0.75, fontSize: '0.85em'}">
|
|
||||||
${ props.row.detail }
|
|
||||||
</span>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-slot:no-data>
|
|
||||||
<div class="full-width row justify-center q-pa-lg"
|
|
||||||
:style="{opacity: 0.6}">
|
|
||||||
Nothing paid yet. Failures and skips land here too.
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-tab-panel>
|
|
||||||
|
|
||||||
</q-tab-panels>
|
|
||||||
</q-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ================================================ new contract -->
|
|
||||||
<q-dialog v-model="contractDialog.show">
|
|
||||||
<q-card style="width: 640px; max-width: 95vw">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">New payroll line</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-gutter-md">
|
|
||||||
|
|
||||||
<q-select outlined dense v-model="contractDialog.data.employee_id"
|
|
||||||
:options="userOptions" emit-value map-options
|
|
||||||
label="Employee *"
|
|
||||||
@update:model-value="contractDialog.data.employee_wallet = null">
|
|
||||||
</q-select>
|
|
||||||
|
|
||||||
<q-select outlined dense v-model="contractDialog.data.employee_wallet"
|
|
||||||
:options="employeeWalletOptions" emit-value map-options
|
|
||||||
:disable="!contractDialog.data.employee_id"
|
|
||||||
label="Pay into (their wallet) *"
|
|
||||||
hint="Only wallets belonging to the selected employee">
|
|
||||||
</q-select>
|
|
||||||
|
|
||||||
<q-select outlined dense v-model="contractDialog.data.source_wallet"
|
|
||||||
:options="sourceWalletOptions" emit-value map-options
|
|
||||||
label="Pay from (your wallet) *"></q-select>
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col-7">
|
|
||||||
<q-input outlined dense type="number" step="any"
|
|
||||||
v-model.number="contractDialog.data.amount" label="Amount *"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-5">
|
|
||||||
<q-select outlined dense use-input input-debounce="0"
|
|
||||||
v-model="contractDialog.data.currency"
|
|
||||||
:options="currencyFilter" @filter="filterCurrencies"
|
|
||||||
label="Currency"
|
|
||||||
hint="Fiat is priced in sats at each payout"></q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col-6">
|
|
||||||
<q-select outlined dense v-model="contractDialog.data.frequency"
|
|
||||||
:options="frequencyOptions" emit-value map-options
|
|
||||||
label="Frequency"></q-select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<q-input outlined dense v-model="contractDialog.data.start_date"
|
|
||||||
mask="####-##-##" label="First payday *"
|
|
||||||
hint="Anchors every later payday">
|
|
||||||
<template v-slot:append>
|
|
||||||
<q-icon name="event" class="cursor-pointer">
|
|
||||||
<q-popup-proxy cover transition-show="scale">
|
|
||||||
<q-date v-model="contractDialog.data.start_date"
|
|
||||||
mask="YYYY-MM-DD"></q-date>
|
|
||||||
</q-popup-proxy>
|
|
||||||
</q-icon>
|
|
||||||
</template>
|
|
||||||
</q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row items-center q-col-gutter-sm">
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-toggle v-model="contractDialog.data.open_ended"
|
|
||||||
label="Open-ended"></q-toggle>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<q-input outlined dense type="number"
|
|
||||||
v-model.number="contractDialog.data.total_periods"
|
|
||||||
:disable="contractDialog.data.open_ended"
|
|
||||||
label="Number of payments"></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-input outlined dense v-model="contractDialog.data.label"
|
|
||||||
label="Label" hint="Shown in this table, e.g. 'Alice — dev retainer'"></q-input>
|
|
||||||
<q-input outlined dense v-model="contractDialog.data.memo"
|
|
||||||
label="Payment memo" hint="What the employee sees on the payment"></q-input>
|
|
||||||
|
|
||||||
<q-toggle v-model="contractDialog.data.backfill"
|
|
||||||
label="Pay paydays that already passed">
|
|
||||||
<q-tooltip>
|
|
||||||
Off by default: a back-dated start normally means "we pay on the
|
|
||||||
15th", not "send five months of back-pay now".
|
|
||||||
</q-tooltip>
|
|
||||||
</q-toggle>
|
|
||||||
|
|
||||||
<q-separator></q-separator>
|
|
||||||
|
|
||||||
<div v-if="preview">
|
|
||||||
<div class="text-subtitle2 q-mb-xs">Next paydays</div>
|
|
||||||
<div class="row q-gutter-xs">
|
|
||||||
<q-chip v-for="d in preview.paydays" :key="d" dense square outline
|
|
||||||
:label="d"></q-chip>
|
|
||||||
<q-chip v-if="preview.truncated" dense square outline label="…"></q-chip>
|
|
||||||
</div>
|
|
||||||
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
|
|
||||||
<span v-if="preview.ends_on">
|
|
||||||
${ preview.total_periods } payment(s), ending ${ preview.ends_on }.
|
|
||||||
</span>
|
|
||||||
<span v-else>Open-ended — pays until paused or cancelled.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
|
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
|
||||||
<q-btn color="primary" label="Create" :loading="contractDialog.saving"
|
|
||||||
:disable="!canSaveContract" @click="saveContract"></q-btn>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<!-- ======================================================= edit -->
|
|
||||||
<q-dialog v-model="editDialog.show">
|
|
||||||
<q-card style="width: 480px; max-width: 95vw">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Edit terms</div>
|
|
||||||
<div class="text-caption" :style="{opacity: 0.7}">
|
|
||||||
The employee, the source wallet and the start date are fixed. Changing
|
|
||||||
them would rewrite what past payouts were for. Cancel and re-create
|
|
||||||
instead.
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section class="q-gutter-md">
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col-7">
|
|
||||||
<q-input outlined dense type="number" step="any"
|
|
||||||
v-model.number="editDialog.data.amount" label="Amount"></q-input>
|
|
||||||
</div>
|
|
||||||
<div class="col-5">
|
|
||||||
<q-select outlined dense use-input input-debounce="0"
|
|
||||||
v-model="editDialog.data.currency"
|
|
||||||
:options="currencyFilter" @filter="filterCurrencies"
|
|
||||||
label="Currency"></q-select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-select outlined dense v-model="editDialog.data.frequency"
|
|
||||||
:options="frequencyOptions" emit-value map-options
|
|
||||||
label="Frequency"></q-select>
|
|
||||||
<div class="row items-center q-col-gutter-sm">
|
|
||||||
<div class="col-auto">
|
|
||||||
<q-toggle v-model="editDialog.data.open_ended" label="Open-ended"></q-toggle>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<q-input outlined dense type="number"
|
|
||||||
v-model.number="editDialog.data.total_periods"
|
|
||||||
:disable="editDialog.data.open_ended"
|
|
||||||
label="Number of payments"></q-input>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-input outlined dense v-model="editDialog.data.label" label="Label"></q-input>
|
|
||||||
<q-input outlined dense v-model="editDialog.data.memo"
|
|
||||||
label="Payment memo"></q-input>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Cancel" v-close-popup></q-btn>
|
|
||||||
<q-btn color="primary" label="Save" @click="saveEdit"></q-btn>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
|
|
||||||
<!-- =================================================== schedule -->
|
|
||||||
<q-dialog v-model="scheduleDialog.show">
|
|
||||||
<q-card style="width: 420px; max-width: 95vw">
|
|
||||||
<q-card-section>
|
|
||||||
<div class="text-h6">Upcoming paydays</div>
|
|
||||||
<div v-if="scheduleDialog.contract" class="text-caption"
|
|
||||||
:style="{opacity: 0.7}">
|
|
||||||
${ scheduleDialog.contract.label || scheduleDialog.contract.employee_username }
|
|
||||||
— ${ scheduleDialog.contract.amount } ${ scheduleDialog.contract.currency }
|
|
||||||
${ scheduleDialog.contract.frequency }
|
|
||||||
</div>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-section>
|
|
||||||
<div v-if="scheduleDialog.preview">
|
|
||||||
<div class="row q-gutter-xs">
|
|
||||||
<q-chip v-for="d in scheduleDialog.preview.paydays" :key="d"
|
|
||||||
dense square outline :label="d"></q-chip>
|
|
||||||
</div>
|
|
||||||
<div v-if="!scheduleDialog.preview.paydays.length" :style="{opacity: 0.6}">
|
|
||||||
No paydays left.
|
|
||||||
</div>
|
|
||||||
<div class="q-mt-sm" :style="{opacity: 0.7, fontSize: '0.9em'}">
|
|
||||||
<span v-if="scheduleDialog.preview.ends_on">
|
|
||||||
Ends ${ scheduleDialog.preview.ends_on }.
|
|
||||||
</span>
|
|
||||||
<span v-else>Open-ended.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<q-spinner v-else color="primary"></q-spinner>
|
|
||||||
</q-card-section>
|
|
||||||
<q-card-actions align="right">
|
|
||||||
<q-btn flat label="Close" v-close-popup></q-btn>
|
|
||||||
</q-card-actions>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
"""Test fixtures for payroll.
|
|
||||||
|
|
||||||
The schedule math is pure, so most tests need nothing but a `Contract`
|
|
||||||
instance — no LNbits DB, no wallets, no event loop. That is the point of
|
|
||||||
keeping `services` split into a pure half and an effectful half.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ..models import Contract, ContractStatus, Frequency
|
|
||||||
|
|
||||||
|
|
||||||
def make_contract(
|
|
||||||
*,
|
|
||||||
contract_id: str = "c1",
|
|
||||||
start_date: str = "2026-01-15",
|
|
||||||
frequency: Frequency = Frequency.monthly,
|
|
||||||
total_periods: int | None = 12,
|
|
||||||
periods_done: int = 0,
|
|
||||||
status: ContractStatus = ContractStatus.active,
|
|
||||||
amount: float = 1000,
|
|
||||||
currency: str = "sat",
|
|
||||||
backfill: bool = False,
|
|
||||||
created_at: str = "2026-01-01",
|
|
||||||
) -> Contract:
|
|
||||||
return Contract(
|
|
||||||
id=contract_id,
|
|
||||||
employee_id="employee-account",
|
|
||||||
employee_username="alice",
|
|
||||||
employee_wallet="wallet-employee",
|
|
||||||
source_wallet="wallet-treasury",
|
|
||||||
amount=amount,
|
|
||||||
currency=currency,
|
|
||||||
frequency=frequency,
|
|
||||||
start_date=start_date,
|
|
||||||
total_periods=total_periods,
|
|
||||||
periods_done=periods_done,
|
|
||||||
status=status,
|
|
||||||
backfill=backfill,
|
|
||||||
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
|
|
||||||
tzinfo=timezone.utc
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
"""CSV export.
|
|
||||||
|
|
||||||
The interesting part is not the formatting — it is that payroll data leaves
|
|
||||||
here for a spreadsheet, and a spreadsheet executes a cell that starts with
|
|
||||||
`=`, `+`, `-` or `@`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from ..export import COLUMNS, csv_safe, payouts_to_csv
|
|
||||||
from ..models import Payout, PayoutStatus
|
|
||||||
|
|
||||||
|
|
||||||
def make_payout(**overrides) -> Payout:
|
|
||||||
return Payout(
|
|
||||||
**{
|
|
||||||
"id": "p1",
|
|
||||||
"contract_id": "c1",
|
|
||||||
"period_index": 0,
|
|
||||||
"payday": "2026-01-15",
|
|
||||||
"status": PayoutStatus.paid,
|
|
||||||
"amount_msat": 800_000,
|
|
||||||
"amount": 800,
|
|
||||||
"currency": "sat",
|
|
||||||
"employee_id": "employee-account",
|
|
||||||
"employee_wallet": "wallet-employee",
|
|
||||||
"source_wallet": "wallet-treasury",
|
|
||||||
"created_at": datetime(2026, 1, 15, 9, 0, tzinfo=timezone.utc),
|
|
||||||
**overrides,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_and_one_row():
|
|
||||||
out = payouts_to_csv([make_payout()])
|
|
||||||
lines = out.strip().splitlines()
|
|
||||||
assert lines[0] == ",".join(COLUMNS)
|
|
||||||
assert lines[1].startswith("2026-01-15,paid,1,800.0,sat,800,c1,0,")
|
|
||||||
|
|
||||||
|
|
||||||
def test_amount_sat_comes_from_the_settled_msat_figure():
|
|
||||||
row = payouts_to_csv(
|
|
||||||
[make_payout(amount=800, currency="EUR", amount_msat=1_234_000)]
|
|
||||||
)
|
|
||||||
assert ",1234," in row # not re-derived from the EUR amount
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failed_row_exports_with_no_sat_amount():
|
|
||||||
out = payouts_to_csv(
|
|
||||||
[make_payout(status=PayoutStatus.failed, amount_msat=None, detail="no funds")]
|
|
||||||
)
|
|
||||||
assert "failed" in out and "no funds" in out
|
|
||||||
|
|
||||||
|
|
||||||
# --- spreadsheet formula injection -----------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_formula_prefixes_are_neutralised():
|
|
||||||
for dangerous in ("=1+1", "+1", "-1", "@SUM(A1)", "\tx", "\rx"):
|
|
||||||
assert csv_safe(dangerous).startswith("'")
|
|
||||||
|
|
||||||
|
|
||||||
def test_ordinary_values_are_untouched():
|
|
||||||
for benign in ("2026-01-15", "paid", "wallet-employee", "800.0", ""):
|
|
||||||
assert csv_safe(benign) == benign
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_hostile_detail_cannot_smuggle_a_formula_into_a_cell():
|
|
||||||
out = payouts_to_csv([make_payout(detail="=cmd|'/c calc'!A1")])
|
|
||||||
assert "'=cmd" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_none_becomes_an_empty_cell():
|
|
||||||
assert csv_safe(None) == ""
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
"""Contract lifecycle transitions.
|
|
||||||
|
|
||||||
Pure functions on the model, so no stubs needed. What matters here is which
|
|
||||||
moves are refused and — the one with money attached — what resuming does to
|
|
||||||
the paydays that fell while the contract was paused.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import ContractStatus
|
|
||||||
from ..services import LifecycleError, cancel, pause, resume
|
|
||||||
from .conftest import make_contract
|
|
||||||
|
|
||||||
# --- pause -----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_pause_keeps_the_schedule_position():
|
|
||||||
contract = make_contract(periods_done=3)
|
|
||||||
pause(contract)
|
|
||||||
assert contract.status == ContractStatus.paused
|
|
||||||
assert contract.periods_done == 3
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
|
|
||||||
)
|
|
||||||
def test_only_an_active_contract_can_be_paused(status):
|
|
||||||
with pytest.raises(LifecycleError):
|
|
||||||
pause(make_contract(status=status))
|
|
||||||
|
|
||||||
|
|
||||||
# --- resume ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_resume_skips_the_paydays_missed_while_paused():
|
|
||||||
"""A pause is a decision not to pay. Resuming in April on a monthly
|
|
||||||
contract paused in January must not fire three transfers."""
|
|
||||||
contract = make_contract(
|
|
||||||
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
|
||||||
)
|
|
||||||
|
|
||||||
resume(contract, today=date(2026, 4, 10))
|
|
||||||
|
|
||||||
assert contract.status == ContractStatus.active
|
|
||||||
assert contract.periods_done == 3 # Jan/Feb/Mar written off
|
|
||||||
# 15 April is still ahead, so it is the next one paid.
|
|
||||||
|
|
||||||
|
|
||||||
def test_resume_with_catch_up_pays_the_missed_paydays():
|
|
||||||
contract = make_contract(
|
|
||||||
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
|
||||||
)
|
|
||||||
|
|
||||||
resume(contract, today=date(2026, 4, 10), catch_up=True)
|
|
||||||
|
|
||||||
assert contract.periods_done == 0 # the backlog is left for the scheduler
|
|
||||||
|
|
||||||
|
|
||||||
def test_resume_does_not_skip_a_payday_falling_today():
|
|
||||||
contract = make_contract(
|
|
||||||
start_date="2026-01-15", periods_done=0, status=ContractStatus.paused
|
|
||||||
)
|
|
||||||
|
|
||||||
resume(contract, today=date(2026, 1, 15))
|
|
||||||
|
|
||||||
assert contract.periods_done == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_resuming_past_the_end_completes_the_contract():
|
|
||||||
contract = make_contract(
|
|
||||||
start_date="2026-01-15",
|
|
||||||
total_periods=2,
|
|
||||||
periods_done=0,
|
|
||||||
status=ContractStatus.paused,
|
|
||||||
)
|
|
||||||
|
|
||||||
resume(contract, today=date(2027, 1, 1))
|
|
||||||
|
|
||||||
assert contract.status == ContractStatus.completed
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
[ContractStatus.active, ContractStatus.cancelled, ContractStatus.completed],
|
|
||||||
)
|
|
||||||
def test_only_a_paused_contract_can_be_resumed(status):
|
|
||||||
with pytest.raises(LifecycleError):
|
|
||||||
resume(make_contract(status=status), today=date(2026, 6, 1))
|
|
||||||
|
|
||||||
|
|
||||||
# --- cancel ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [ContractStatus.active, ContractStatus.paused])
|
|
||||||
def test_cancel_from_a_live_status(status):
|
|
||||||
contract = make_contract(status=status)
|
|
||||||
cancel(contract)
|
|
||||||
assert contract.status == ContractStatus.cancelled
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [ContractStatus.cancelled, ContractStatus.completed])
|
|
||||||
def test_terminal_contracts_cannot_be_cancelled_again(status):
|
|
||||||
with pytest.raises(LifecycleError):
|
|
||||||
cancel(make_contract(status=status))
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
"""Payout sequencing.
|
|
||||||
|
|
||||||
`pay_period` itself talks to LNbits wallets, so it is stubbed here — what
|
|
||||||
these tests pin down is the surrounding contract: which periods get
|
|
||||||
attempted, in what order, and what a failure does to the contract's
|
|
||||||
position. Those are the invariants that decide whether a payday can be
|
|
||||||
paid twice or silently dropped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import ContractStatus, PayoutStatus
|
|
||||||
from ..services import (
|
|
||||||
MAX_PERIOD_ATTEMPTS,
|
|
||||||
LifecycleError,
|
|
||||||
PeriodOutcome,
|
|
||||||
pay_now,
|
|
||||||
run_due_periods,
|
|
||||||
)
|
|
||||||
from .conftest import make_contract
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def payroll_stub(monkeypatch):
|
|
||||||
"""Replace the DB and the wallet transfer with in-memory doubles.
|
|
||||||
|
|
||||||
Returns a recorder holding the contract row and the period indices that
|
|
||||||
`pay_period` was asked to settle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Stub:
|
|
||||||
def __init__(self):
|
|
||||||
self.contract = None
|
|
||||||
self.attempted: list[int] = []
|
|
||||||
self.outcome_status = PayoutStatus.paid
|
|
||||||
self.ledger: list = []
|
|
||||||
# Failures already on record for (contract, period), as the real
|
|
||||||
# ledger would report them.
|
|
||||||
self.prior_failures = 0
|
|
||||||
|
|
||||||
stub = Stub()
|
|
||||||
|
|
||||||
async def fake_get_contract(_contract_id):
|
|
||||||
return stub.contract
|
|
||||||
|
|
||||||
async def fake_update_contract(contract):
|
|
||||||
stub.contract = contract
|
|
||||||
return contract
|
|
||||||
|
|
||||||
async def fake_create_payout(payout):
|
|
||||||
stub.ledger.append(payout)
|
|
||||||
return payout
|
|
||||||
|
|
||||||
async def fake_count_period_failures(_contract_id, _period_index):
|
|
||||||
return stub.prior_failures
|
|
||||||
|
|
||||||
async def fake_pay_period(contract, index, payday):
|
|
||||||
stub.attempted.append(index)
|
|
||||||
paid = stub.outcome_status == PayoutStatus.paid
|
|
||||||
return PeriodOutcome(
|
|
||||||
index=index,
|
|
||||||
payday=payday,
|
|
||||||
status=stub.outcome_status,
|
|
||||||
detail="" if paid else "stubbed failure",
|
|
||||||
amount_msat=int(contract.amount) * 1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .. import services
|
|
||||||
|
|
||||||
monkeypatch.setattr(services.crud, "get_contract", fake_get_contract)
|
|
||||||
monkeypatch.setattr(services.crud, "update_contract", fake_update_contract)
|
|
||||||
monkeypatch.setattr(services.crud, "create_payout", fake_create_payout)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
services.crud, "count_period_failures", fake_count_period_failures
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(services, "pay_period", fake_pay_period)
|
|
||||||
# Locks are created inside whichever loop is running; each test drives its
|
|
||||||
# own asyncio.run, so a lock left over from a previous test would raise.
|
|
||||||
services._contract_locks.clear()
|
|
||||||
return stub
|
|
||||||
|
|
||||||
|
|
||||||
def test_backdated_periods_are_skipped_when_backfill_is_off(payroll_stub):
|
|
||||||
"""A contract created in May with a January start is anchoring a payday
|
|
||||||
("we pay on the 15th"), not asking for four months of back-pay.
|
|
||||||
|
|
||||||
The boundary is the contract's own creation date: Jan-Apr predate it and
|
|
||||||
are skipped, the 15 May period does not and is paid normally.
|
|
||||||
"""
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-05-01", backfill=False
|
|
||||||
)
|
|
||||||
|
|
||||||
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 5, 20)))
|
|
||||||
|
|
||||||
assert [p.status for p in outcomes] == [PayoutStatus.skipped] * 4 + [
|
|
||||||
PayoutStatus.paid
|
|
||||||
]
|
|
||||||
assert payroll_stub.attempted == [4] # only the first in-life period paid
|
|
||||||
# Skipped periods still consume the schedule, so the next payday is June.
|
|
||||||
assert payroll_stub.contract.periods_done == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_backfill_pays_the_backlog(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-05-01", backfill=True
|
|
||||||
)
|
|
||||||
|
|
||||||
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 3, 20)))
|
|
||||||
|
|
||||||
assert [p.status for p in outcomes] == [PayoutStatus.paid] * 3
|
|
||||||
assert payroll_stub.attempted == [0, 1, 2]
|
|
||||||
assert payroll_stub.contract.periods_done == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failure_halts_the_backlog_and_holds_the_position(payroll_stub):
|
|
||||||
"""The crux: a failed period must not advance the counter, or that
|
|
||||||
payday is gone. And later periods must not jump the queue."""
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-01-01", backfill=True
|
|
||||||
)
|
|
||||||
payroll_stub.outcome_status = PayoutStatus.failed
|
|
||||||
|
|
||||||
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 4, 20)))
|
|
||||||
|
|
||||||
assert [p.status for p in outcomes] == [PayoutStatus.failed]
|
|
||||||
assert payroll_stub.attempted == [0] # period 1 did not jump ahead
|
|
||||||
assert payroll_stub.contract.periods_done == 0 # retried next tick
|
|
||||||
|
|
||||||
|
|
||||||
def test_contract_completes_when_its_periods_run_out(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15",
|
|
||||||
created_at="2026-01-01",
|
|
||||||
total_periods=2,
|
|
||||||
backfill=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 6, 1)))
|
|
||||||
|
|
||||||
assert payroll_stub.contract.periods_done == 2
|
|
||||||
assert payroll_stub.contract.status == ContractStatus.completed
|
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_runs_before_the_first_payday(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-06-01", created_at="2026-05-01"
|
|
||||||
)
|
|
||||||
|
|
||||||
outcomes = asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 5, 31)))
|
|
||||||
|
|
||||||
assert outcomes == []
|
|
||||||
assert payroll_stub.attempted == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- ledger ----------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_attempt_is_recorded_including_skips(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-03-01", backfill=False
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 3, 20)))
|
|
||||||
|
|
||||||
assert [(p.period_index, p.status) for p in payroll_stub.ledger] == [
|
|
||||||
(0, PayoutStatus.skipped),
|
|
||||||
(1, PayoutStatus.skipped),
|
|
||||||
(2, PayoutStatus.paid),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_ledger_row_carries_the_terms_in_force(payroll_stub):
|
|
||||||
"""Rows have to still read correctly after the contract is edited or
|
|
||||||
deleted, so they copy the terms rather than pointing at them."""
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-01-01", amount=800, currency="EUR"
|
|
||||||
)
|
|
||||||
|
|
||||||
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 15)))
|
|
||||||
|
|
||||||
row = payroll_stub.ledger[0]
|
|
||||||
assert (row.amount, row.currency) == (800, "EUR")
|
|
||||||
assert row.employee_wallet == "wallet-employee"
|
|
||||||
assert row.source_wallet == "wallet-treasury"
|
|
||||||
assert row.amount_sat == 800 # from the stubbed invoice, not amount x rate
|
|
||||||
|
|
||||||
|
|
||||||
def test_repeated_failure_pauses_the_contract(payroll_stub):
|
|
||||||
"""A payday that cannot be funded is a fact somebody has to act on.
|
|
||||||
Retrying it silently forever is how a missed salary goes unnoticed."""
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-01-01"
|
|
||||||
)
|
|
||||||
payroll_stub.outcome_status = PayoutStatus.failed
|
|
||||||
payroll_stub.prior_failures = MAX_PERIOD_ATTEMPTS - 1
|
|
||||||
|
|
||||||
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 20)))
|
|
||||||
|
|
||||||
assert payroll_stub.contract.status == ContractStatus.paused
|
|
||||||
# The position did not move, so resuming retries the same payday.
|
|
||||||
assert payroll_stub.contract.periods_done == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_failure_under_the_cap_leaves_the_contract_active(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-01-01"
|
|
||||||
)
|
|
||||||
payroll_stub.outcome_status = PayoutStatus.failed
|
|
||||||
payroll_stub.prior_failures = MAX_PERIOD_ATTEMPTS - 2
|
|
||||||
|
|
||||||
asyncio.run(run_due_periods(payroll_stub.contract, date(2026, 1, 20)))
|
|
||||||
|
|
||||||
assert payroll_stub.contract.status == ContractStatus.active
|
|
||||||
assert payroll_stub.ledger[-1].attempt == MAX_PERIOD_ATTEMPTS - 1
|
|
||||||
|
|
||||||
|
|
||||||
# --- off-cycle payout ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_pay_now_settles_the_next_period_before_its_payday(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-12-01", created_at="2026-01-01"
|
|
||||||
)
|
|
||||||
|
|
||||||
payout = asyncio.run(pay_now(payroll_stub.contract))
|
|
||||||
|
|
||||||
assert payout.status == PayoutStatus.paid
|
|
||||||
assert payout.period_index == 0
|
|
||||||
assert payroll_stub.contract.periods_done == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_pay_now_ignores_the_back_dated_skip(payroll_stub):
|
|
||||||
"""`_settle` skips back-dated periods to stop a new contract firing
|
|
||||||
surprise back-pay. An operator explicitly asking to pay is not a
|
|
||||||
surprise, so pay_now goes straight to the transfer."""
|
|
||||||
payroll_stub.contract = make_contract(
|
|
||||||
start_date="2026-01-15", created_at="2026-05-01", backfill=False
|
|
||||||
)
|
|
||||||
|
|
||||||
payout = asyncio.run(pay_now(payroll_stub.contract))
|
|
||||||
|
|
||||||
assert payout.status == PayoutStatus.paid
|
|
||||||
assert payroll_stub.attempted == [0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_pay_now_refuses_a_paused_contract(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(status=ContractStatus.paused)
|
|
||||||
with pytest.raises(LifecycleError):
|
|
||||||
asyncio.run(pay_now(payroll_stub.contract))
|
|
||||||
|
|
||||||
|
|
||||||
def test_pay_now_refuses_an_exhausted_contract(payroll_stub):
|
|
||||||
payroll_stub.contract = make_contract(total_periods=2, periods_done=2)
|
|
||||||
with pytest.raises(LifecycleError):
|
|
||||||
asyncio.run(pay_now(payroll_stub.contract))
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
"""Schedule math.
|
|
||||||
|
|
||||||
These are the cases that make month-anchored payroll wrong in practice:
|
|
||||||
month-end clamping, clamping that must not become permanent, leap days, and
|
|
||||||
a schedule that must not drift no matter how late the scheduler runs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ..models import ContractStatus, Frequency
|
|
||||||
from ..services import (
|
|
||||||
MAX_CATCH_UP_PERIODS,
|
|
||||||
add_months,
|
|
||||||
due_period_indices,
|
|
||||||
final_payday,
|
|
||||||
next_payday,
|
|
||||||
occurrence_on,
|
|
||||||
paydays_from,
|
|
||||||
upcoming_paydays,
|
|
||||||
)
|
|
||||||
from .conftest import make_contract
|
|
||||||
|
|
||||||
# --- month arithmetic ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_months_clamps_into_a_short_month():
|
|
||||||
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
|
|
||||||
|
|
||||||
|
|
||||||
def test_clamping_is_not_permanent():
|
|
||||||
"""The whole reason paydays are computed from the anchor and not from the
|
|
||||||
previous payday: 31 Jan must pay 28 Feb and then *31* Mar, not 28 Mar."""
|
|
||||||
anchor = date(2026, 1, 31)
|
|
||||||
assert add_months(anchor, 1) == date(2026, 2, 28)
|
|
||||||
assert add_months(anchor, 2) == date(2026, 3, 31)
|
|
||||||
assert add_months(anchor, 3) == date(2026, 4, 30)
|
|
||||||
assert add_months(anchor, 4) == date(2026, 5, 31)
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_months_crosses_year_boundaries():
|
|
||||||
assert add_months(date(2026, 11, 30), 3) == date(2027, 2, 28)
|
|
||||||
assert add_months(date(2026, 3, 15), -4) == date(2025, 11, 15)
|
|
||||||
|
|
||||||
|
|
||||||
def test_leap_day_anchor_clamps_in_common_years():
|
|
||||||
leap = date(2028, 2, 29)
|
|
||||||
assert add_months(leap, 12) == date(2029, 2, 28)
|
|
||||||
assert add_months(leap, 48) == date(2032, 2, 29)
|
|
||||||
|
|
||||||
|
|
||||||
# --- occurrences -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("frequency", "index", "expected"),
|
|
||||||
[
|
|
||||||
(Frequency.daily, 0, date(2026, 1, 15)),
|
|
||||||
(Frequency.daily, 20, date(2026, 2, 4)),
|
|
||||||
(Frequency.weekly, 3, date(2026, 2, 5)),
|
|
||||||
(Frequency.biweekly, 2, date(2026, 2, 12)),
|
|
||||||
(Frequency.monthly, 2, date(2026, 3, 15)),
|
|
||||||
(Frequency.quarterly, 2, date(2026, 7, 15)),
|
|
||||||
(Frequency.yearly, 2, date(2028, 1, 15)),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_occurrence_on(frequency, index, expected):
|
|
||||||
assert occurrence_on(date(2026, 1, 15), frequency, index) == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_period_zero_is_the_start_date_for_every_frequency():
|
|
||||||
start = date(2026, 6, 30)
|
|
||||||
for frequency in Frequency:
|
|
||||||
assert occurrence_on(start, frequency, 0) == start
|
|
||||||
|
|
||||||
|
|
||||||
def test_schedule_does_not_drift_over_a_year():
|
|
||||||
"""A monthly contract must land on the same day twelve months later —
|
|
||||||
incrementally advancing a stored date is what would break this."""
|
|
||||||
start = date(2026, 1, 15)
|
|
||||||
assert occurrence_on(start, Frequency.monthly, 12) == date(2027, 1, 15)
|
|
||||||
|
|
||||||
|
|
||||||
# --- due periods -----------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_nothing_is_due_before_the_start_date():
|
|
||||||
contract = make_contract(start_date="2026-03-01")
|
|
||||||
assert due_period_indices(contract, date(2026, 2, 28)) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_start_date_itself_is_due():
|
|
||||||
contract = make_contract(start_date="2026-03-01")
|
|
||||||
assert due_period_indices(contract, date(2026, 3, 1)) == [0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_backlog_returns_every_missed_period_in_order():
|
|
||||||
contract = make_contract(start_date="2026-01-15", frequency=Frequency.monthly)
|
|
||||||
assert due_period_indices(contract, date(2026, 4, 20)) == [0, 1, 2, 3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_periods_already_done_are_not_due_again():
|
|
||||||
contract = make_contract(start_date="2026-01-15", periods_done=3)
|
|
||||||
assert due_period_indices(contract, date(2026, 4, 20)) == [3]
|
|
||||||
|
|
||||||
|
|
||||||
def test_due_periods_stop_at_the_period_cap():
|
|
||||||
contract = make_contract(start_date="2026-01-15", total_periods=2)
|
|
||||||
assert due_period_indices(contract, date(2026, 12, 31)) == [0, 1]
|
|
||||||
|
|
||||||
|
|
||||||
def test_catch_up_is_capped_for_open_ended_contracts():
|
|
||||||
"""A daily contract with a badly back-dated start must not try to fire
|
|
||||||
hundreds of transfers in one tick."""
|
|
||||||
contract = make_contract(
|
|
||||||
start_date="2020-01-01", frequency=Frequency.daily, total_periods=None
|
|
||||||
)
|
|
||||||
assert len(due_period_indices(contract, date(2026, 1, 1))) == MAX_CATCH_UP_PERIODS
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"status",
|
|
||||||
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
|
|
||||||
)
|
|
||||||
def test_only_active_contracts_are_due(status):
|
|
||||||
contract = make_contract(start_date="2026-01-15", status=status)
|
|
||||||
assert due_period_indices(contract, date(2026, 6, 1)) == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- previews --------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_next_payday_follows_the_position():
|
|
||||||
contract = make_contract(start_date="2026-01-31", periods_done=1)
|
|
||||||
assert next_payday(contract) == date(2026, 2, 28)
|
|
||||||
|
|
||||||
|
|
||||||
def test_next_payday_is_none_once_exhausted():
|
|
||||||
contract = make_contract(total_periods=3, periods_done=3)
|
|
||||||
assert next_payday(contract) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_upcoming_paydays_are_truncated_by_the_period_cap():
|
|
||||||
contract = make_contract(start_date="2026-01-15", total_periods=3, periods_done=1)
|
|
||||||
assert upcoming_paydays(contract, 10) == [date(2026, 2, 15), date(2026, 3, 15)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_upcoming_paydays_are_unbounded_for_open_ended_contracts():
|
|
||||||
contract = make_contract(start_date="2026-01-15", total_periods=None)
|
|
||||||
assert len(upcoming_paydays(contract, 24)) == 24
|
|
||||||
|
|
||||||
|
|
||||||
# --- preview from loose terms ----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_paydays_from_draws_a_window_of_the_schedule():
|
|
||||||
assert paydays_from(date(2026, 1, 31), Frequency.monthly, 0, 4) == [
|
|
||||||
date(2026, 1, 31),
|
|
||||||
date(2026, 2, 28),
|
|
||||||
date(2026, 3, 31),
|
|
||||||
date(2026, 4, 30),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_paydays_from_starts_at_the_given_index():
|
|
||||||
assert paydays_from(date(2026, 1, 15), Frequency.weekly, 2, 2) == [
|
|
||||||
date(2026, 1, 29),
|
|
||||||
date(2026, 2, 5),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_paydays_from_stops_at_the_period_cap():
|
|
||||||
drawn = paydays_from(date(2026, 1, 15), Frequency.monthly, 0, 10, total_periods=3)
|
|
||||||
assert len(drawn) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_final_payday_is_the_last_period_not_one_past_it():
|
|
||||||
"""12 monthly payments from 15 Jan end on 15 Dec, not 15 Jan next year."""
|
|
||||||
assert final_payday(date(2026, 1, 15), Frequency.monthly, 12) == date(2026, 12, 15)
|
|
||||||
|
|
||||||
|
|
||||||
def test_open_ended_contracts_have_no_final_payday():
|
|
||||||
assert final_payday(date(2026, 1, 15), Frequency.monthly, None) is None
|
|
||||||
35
views.py
35
views.py
|
|
@ -1,35 +0,0 @@
|
||||||
"""Frontend route — serves the operator console.
|
|
||||||
|
|
||||||
Gated the same way the API is: super user only. `check_user_exists` is used
|
|
||||||
rather than `check_super_user` because the template needs the full `User`
|
|
||||||
(its wallets populate the source-wallet picker) and `check_super_user`
|
|
||||||
resolves to an `Account`; the privilege check is then made explicitly off
|
|
||||||
`user.super_user`. Rejecting here is a UX nicety — the API behind the page
|
|
||||||
is gated independently and does not trust this route.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
from lnbits.core.models import User
|
|
||||||
from lnbits.decorators import check_user_exists
|
|
||||||
from lnbits.helpers import template_renderer
|
|
||||||
|
|
||||||
payroll_generic_router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def payroll_renderer():
|
|
||||||
return template_renderer(["payroll/templates"])
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_generic_router.get("/", response_class=HTMLResponse)
|
|
||||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
|
||||||
if not user.super_user:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.FORBIDDEN,
|
|
||||||
"Payroll is restricted to the instance super user.",
|
|
||||||
)
|
|
||||||
return payroll_renderer().TemplateResponse(
|
|
||||||
"payroll/index.html", {"request": request, "user": user.json()}
|
|
||||||
)
|
|
||||||
409
views_api.py
409
views_api.py
|
|
@ -1,409 +0,0 @@
|
||||||
"""Payroll REST API.
|
|
||||||
|
|
||||||
Every route here is gated at the *router* level on `check_super_user`, not
|
|
||||||
per-endpoint. Payroll reads the account directory and moves money between
|
|
||||||
wallets the caller does not own, so the gate has to be instance-admin, and
|
|
||||||
putting it on the router means a new endpoint cannot be added ungated by
|
|
||||||
forgetting a decorator.
|
|
||||||
|
|
||||||
`require_admin_key` would be the wrong choice and is easy to reach for by
|
|
||||||
mistake: it authorises writes to the *caller's own* wallet, which any user
|
|
||||||
has for their own wallets. It is not instance-admin. (See the auth table in
|
|
||||||
the workspace CLAUDE.md.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from http import HTTPStatus
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
||||||
from lnbits.core.crud import get_wallet
|
|
||||||
from lnbits.core.models import WalletTypeInfo
|
|
||||||
from lnbits.decorators import check_super_user, require_invoice_key
|
|
||||||
from lnbits.utils.exchange_rates import allowed_currencies
|
|
||||||
|
|
||||||
from . import crud, services
|
|
||||||
from .accounts import list_directory_users, owns_wallet
|
|
||||||
from .export import payouts_to_csv
|
|
||||||
from .models import (
|
|
||||||
Contract,
|
|
||||||
CreateContract,
|
|
||||||
DirectoryUser,
|
|
||||||
Payout,
|
|
||||||
PayoutStatus,
|
|
||||||
SchedulePreview,
|
|
||||||
SchedulePreviewRequest,
|
|
||||||
UpdateContract,
|
|
||||||
)
|
|
||||||
|
|
||||||
payroll_api_router = APIRouter(dependencies=[Depends(check_super_user)])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Directory
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/users")
|
|
||||||
async def api_list_users(search: str | None = None) -> list[DirectoryUser]:
|
|
||||||
"""Accounts + their wallets, for the "select a user" picker."""
|
|
||||||
return await list_directory_users(search=search)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/currencies")
|
|
||||||
async def api_list_currencies() -> list[str]:
|
|
||||||
"""Currencies a contract may be denominated in. "sat" first because a
|
|
||||||
sat-denominated contract needs no FX at all."""
|
|
||||||
return ["sat", *allowed_currencies()]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Contract validation
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_terms(data: CreateContract) -> str:
|
|
||||||
"""Check a proposed contract against the world outside its own row, and
|
|
||||||
return the employee's display name for the snapshot label.
|
|
||||||
|
|
||||||
The employee/wallet pair arrives from a form as two independent ids;
|
|
||||||
nothing further down the write path re-checks that they belong
|
|
||||||
together, so this is where a payout gets stopped from being aimed at
|
|
||||||
somebody else's wallet.
|
|
||||||
"""
|
|
||||||
users = {u.id: u for u in await list_directory_users()}
|
|
||||||
employee = users.get(data.employee_id)
|
|
||||||
if not employee:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Employee account not found.")
|
|
||||||
|
|
||||||
if not await owns_wallet(data.employee_id, data.employee_wallet):
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST,
|
|
||||||
"Destination wallet does not belong to the selected employee.",
|
|
||||||
)
|
|
||||||
|
|
||||||
source = await get_wallet(data.source_wallet)
|
|
||||||
if not source:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Source wallet not found.")
|
|
||||||
|
|
||||||
if data.source_wallet == data.employee_wallet:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST, "A contract cannot pay a wallet from itself."
|
|
||||||
)
|
|
||||||
|
|
||||||
if data.currency != "sat" and data.currency not in allowed_currencies():
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{data.currency}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
return employee.display_name
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Contracts
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/contracts")
|
|
||||||
async def api_list_contracts() -> list[Contract]:
|
|
||||||
return await crud.get_contracts()
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/contracts/{contract_id}")
|
|
||||||
async def api_get_contract(contract_id: str) -> Contract:
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
return contract
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/contracts", status_code=HTTPStatus.CREATED)
|
|
||||||
async def api_create_contract(data: CreateContract) -> Contract:
|
|
||||||
display_name = await _validate_terms(data)
|
|
||||||
return await crud.create_contract(data, employee_username=display_name)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.put("/api/v1/contracts/{contract_id}")
|
|
||||||
async def api_update_contract(contract_id: str, data: UpdateContract) -> Contract:
|
|
||||||
"""Patch the terms of a contract.
|
|
||||||
|
|
||||||
Only amount/currency/frequency/total_periods/label/memo are patchable —
|
|
||||||
UpdateContract does not carry the recipient, the source wallet or the
|
|
||||||
start date. Those three are the contract's identity: changing them would
|
|
||||||
retroactively alter what the payouts already made were *for*, and
|
|
||||||
changing the start date would silently re-anchor every remaining payday.
|
|
||||||
Cancel and re-create instead.
|
|
||||||
"""
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
|
|
||||||
patch = data.dict(exclude_unset=True, exclude_none=True)
|
|
||||||
|
|
||||||
if "currency" in patch and patch["currency"] != "sat":
|
|
||||||
if patch["currency"] not in allowed_currencies():
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST, f"Unsupported currency '{patch['currency']}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Shrinking a contract below what it has already paid would leave it with
|
|
||||||
# negative periods remaining; treat the request as an error rather than
|
|
||||||
# silently completing it.
|
|
||||||
if "total_periods" in patch and patch["total_periods"] is not None:
|
|
||||||
if patch["total_periods"] < contract.periods_done:
|
|
||||||
raise HTTPException(
|
|
||||||
HTTPStatus.BAD_REQUEST,
|
|
||||||
f"Contract has already consumed {contract.periods_done} periods.",
|
|
||||||
)
|
|
||||||
|
|
||||||
for field, value in patch.items():
|
|
||||||
setattr(contract, field, value)
|
|
||||||
|
|
||||||
return await crud.update_contract(contract)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.delete("/api/v1/contracts/{contract_id}", status_code=HTTPStatus.OK)
|
|
||||||
async def api_delete_contract(contract_id: str) -> None:
|
|
||||||
"""Hard-delete a contract row.
|
|
||||||
|
|
||||||
This is the "created it by mistake" escape hatch, not the way to stop a
|
|
||||||
running payroll — deleting throws away the schedule position along with
|
|
||||||
the row. Cancelling keeps the record.
|
|
||||||
"""
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
await crud.delete_contract(contract_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Lifecycle
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _transition(contract_id: str, apply) -> Contract:
|
|
||||||
"""Load a contract, apply a services-layer transition, persist it.
|
|
||||||
|
|
||||||
The transitions themselves are pure functions on the model — they raise
|
|
||||||
LifecycleError for an illegal move, which becomes a 409 here rather than
|
|
||||||
a 400, because the request is well-formed and it is the contract's
|
|
||||||
current state that refuses it.
|
|
||||||
"""
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
try:
|
|
||||||
apply(contract)
|
|
||||||
except services.LifecycleError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc
|
|
||||||
return await crud.update_contract(contract)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/contracts/{contract_id}/pause")
|
|
||||||
async def api_pause_contract(contract_id: str) -> Contract:
|
|
||||||
"""Stop paying without losing the schedule position."""
|
|
||||||
return await _transition(contract_id, services.pause)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/contracts/{contract_id}/resume")
|
|
||||||
async def api_resume_contract(contract_id: str, catch_up: bool = False) -> Contract:
|
|
||||||
"""Put a paused contract back to work.
|
|
||||||
|
|
||||||
Paydays missed during the pause are skipped by default — a pause is a
|
|
||||||
decision not to pay them, and resuming into an unannounced multi-period
|
|
||||||
transfer is the opposite of what "resume" implies. `catch_up=true` pays
|
|
||||||
them, for a pause that was an operational hold rather than a decision
|
|
||||||
about the money.
|
|
||||||
"""
|
|
||||||
return await _transition(
|
|
||||||
contract_id, lambda c: services.resume(c, catch_up=catch_up)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/contracts/{contract_id}/cancel")
|
|
||||||
async def api_cancel_contract(contract_id: str) -> Contract:
|
|
||||||
"""Stop a contract for good, keeping the row and its history.
|
|
||||||
|
|
||||||
The right way to end a payroll line. DELETE is the "created it by
|
|
||||||
mistake" escape hatch and discards the record entirely.
|
|
||||||
"""
|
|
||||||
return await _transition(contract_id, services.cancel)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Payout ledger
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/payouts")
|
|
||||||
async def api_list_payouts(
|
|
||||||
contract_id: str | None = None,
|
|
||||||
status: PayoutStatus | None = None,
|
|
||||||
limit: int = 200,
|
|
||||||
) -> list[Payout]:
|
|
||||||
"""Every payout attempt, newest first.
|
|
||||||
|
|
||||||
Includes failures and skips, not just successes — "why did nobody get
|
|
||||||
paid on the 1st" is the question this endpoint exists to answer.
|
|
||||||
"""
|
|
||||||
return await crud.get_payouts(
|
|
||||||
contract_id=contract_id, status=status, limit=min(limit, 1000)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schedule preview and off-cycle payout
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _preview(
|
|
||||||
start: date, frequency, total_periods: int | None, first_index: int, count: int
|
|
||||||
) -> SchedulePreview:
|
|
||||||
paydays = services.paydays_from(start, frequency, first_index, count, total_periods)
|
|
||||||
end = services.final_payday(start, frequency, total_periods)
|
|
||||||
return SchedulePreview(
|
|
||||||
paydays=[d.isoformat() for d in paydays],
|
|
||||||
total_periods=total_periods,
|
|
||||||
ends_on=end.isoformat() if end else None,
|
|
||||||
truncated=total_periods is None or first_index + count < total_periods,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/schedule/preview")
|
|
||||||
async def api_preview_schedule(data: SchedulePreviewRequest) -> SchedulePreview:
|
|
||||||
"""Draw a contract's calendar before it exists.
|
|
||||||
|
|
||||||
The cheapest moment to notice a mistyped start date or the wrong
|
|
||||||
frequency is before anything is saved, so this takes loose terms rather
|
|
||||||
than a full contract — no employee or wallet needed.
|
|
||||||
"""
|
|
||||||
return _preview(
|
|
||||||
datetime.strptime(data.start_date, "%Y-%m-%d").date(),
|
|
||||||
data.frequency,
|
|
||||||
data.total_periods,
|
|
||||||
0,
|
|
||||||
data.count,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/contracts/{contract_id}/schedule")
|
|
||||||
async def api_contract_schedule(contract_id: str, count: int = 12) -> SchedulePreview:
|
|
||||||
"""The paydays still ahead of a live contract, from its current position."""
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
return _preview(
|
|
||||||
services.parse_start_date(contract),
|
|
||||||
contract.frequency,
|
|
||||||
contract.total_periods,
|
|
||||||
contract.periods_done,
|
|
||||||
max(1, min(count, 120)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.post("/api/v1/contracts/{contract_id}/pay-now")
|
|
||||||
async def api_pay_now(contract_id: str) -> Payout:
|
|
||||||
"""Settle the next period immediately, whatever the calendar says.
|
|
||||||
|
|
||||||
Covers both "run it now" (do not wait for the tick) and "pay it early",
|
|
||||||
which are the same operation — the next period settles and is consumed —
|
|
||||||
so they are one endpoint rather than two that differ only in whether
|
|
||||||
today happens to be the payday.
|
|
||||||
|
|
||||||
Recorded in the ledger like any other payout, with the early-payment
|
|
||||||
noted in its detail. Returns the ledger row, including a failed one:
|
|
||||||
the caller wants to know *why* a manual payout did not land.
|
|
||||||
"""
|
|
||||||
contract = await crud.get_contract(contract_id)
|
|
||||||
if not contract:
|
|
||||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Contract not found.")
|
|
||||||
try:
|
|
||||||
return await services.pay_now(contract)
|
|
||||||
except services.LifecycleError as exc:
|
|
||||||
raise HTTPException(HTTPStatus.CONFLICT, str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_api_router.get("/api/v1/payouts.csv")
|
|
||||||
async def api_export_payouts_csv(
|
|
||||||
contract_id: str | None = None,
|
|
||||||
status: PayoutStatus | None = None,
|
|
||||||
since: str | None = None,
|
|
||||||
until: str | None = None,
|
|
||||||
) -> Response:
|
|
||||||
"""The ledger as CSV, for accounting.
|
|
||||||
|
|
||||||
`since`/`until` bound the payday rather than the row's timestamp, so an
|
|
||||||
accounting period contains the paydays that belong to it even when one
|
|
||||||
of them took three days of retries to settle.
|
|
||||||
"""
|
|
||||||
payouts = await crud.get_payouts(
|
|
||||||
contract_id=contract_id,
|
|
||||||
status=status,
|
|
||||||
since=since,
|
|
||||||
until=until,
|
|
||||||
limit=100_000,
|
|
||||||
)
|
|
||||||
filename = f"payroll-{since or 'all'}-{until or 'all'}.csv"
|
|
||||||
return Response(
|
|
||||||
content=payouts_to_csv(payouts),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Employee-facing surface
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# A separate router with a separate gate. Employees are not admins, so this
|
|
||||||
# is keyed on a wallet's invoice key: whoever holds the read key for a
|
|
||||||
# wallet may see what payroll has paid *into* that wallet, and nothing else.
|
|
||||||
# Keeping it off `payroll_api_router` is what stops it inheriting — or
|
|
||||||
# accidentally shedding — the super-user gate.
|
|
||||||
payroll_employee_router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_employee_router.get("/api/v1/my/payouts")
|
|
||||||
async def api_my_payouts(
|
|
||||||
since: str | None = None,
|
|
||||||
until: str | None = None,
|
|
||||||
limit: int = 200,
|
|
||||||
key: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> list[Payout]:
|
|
||||||
"""Payslips for the wallet whose key signed the request.
|
|
||||||
|
|
||||||
Scoped to the wallet rather than to the account on purpose: the invoice
|
|
||||||
key names exactly one wallet, so there is no lookup that could widen the
|
|
||||||
result to a sibling wallet the key does not cover.
|
|
||||||
"""
|
|
||||||
return await crud.get_payouts(
|
|
||||||
employee_wallet=key.wallet.id,
|
|
||||||
since=since,
|
|
||||||
until=until,
|
|
||||||
limit=min(limit, 1000),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_employee_router.get("/api/v1/my/payouts.csv")
|
|
||||||
async def api_my_payouts_csv(
|
|
||||||
since: str | None = None,
|
|
||||||
until: str | None = None,
|
|
||||||
key: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> Response:
|
|
||||||
"""The same payslips as CSV, so an employee can file their own record."""
|
|
||||||
payouts = await crud.get_payouts(
|
|
||||||
employee_wallet=key.wallet.id, since=since, until=until, limit=100_000
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
content=payouts_to_csv(payouts),
|
|
||||||
media_type="text/csv",
|
|
||||||
headers={"Content-Disposition": 'attachment; filename="payslips.csv"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@payroll_employee_router.get("/api/v1/my/contracts")
|
|
||||||
async def api_my_contracts(
|
|
||||||
key: WalletTypeInfo = Depends(require_invoice_key),
|
|
||||||
) -> list[Contract]:
|
|
||||||
"""The payroll lines paying into this wallet — what is still owed, and
|
|
||||||
when the next one lands."""
|
|
||||||
return await crud.get_contracts_for_wallet(key.wallet.id)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue