feat: payroll contract schema and CRUD

The one entity payroll needs: a standing instruction to pay an employee a
fixed amount, at a fixed cadence, from a given start date, a given number
of times.

Two design decisions worth reviewing here, both documented in
docs/data-model.md:

- Schedule position is `periods_done` (a counter), not a stored
  `next_run_at`. Every payday is recomputed as occurrence(start_date,
  frequency, n), so a late tick cannot make the schedule drift, and a
  contract anchored on the 31st pays 28 Feb then 31 Mar rather than being
  permanently pinned to the 28th.
- `periods_done` counts periods *consumed* (paid or deliberately skipped),
  not periods successfully paid. A failed payout leaves it untouched so the
  next tick retries that payday instead of dropping it.

There is no employee table — an employee is an LNbits account. Only
`employee_username` is copied, and only as a display label so history stays
readable after a rename; authorisation always goes through `employee_id`.

Ordinary migrations, not the migrations_fork.py split: this is an
aiolabs-original extension, so there is no upstream migrations.py to stay
byte-identical with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
Padreug 2026-08-31 13:38:25 +02:00
commit f253b51aa9
4 changed files with 424 additions and 0 deletions

89
crud.py Normal file
View file

@ -0,0 +1,89 @@
"""Payroll CRUD.
Thin by design: the interesting logic (when a period falls due, whether a
payout may run) lives in services.py, and this module only owns row
lifecycle. The one rule enforced here is that `updated_at` is stamped on
every write, so "when did this contract last change" is answerable without
a separate audit trail.
"""
from datetime import datetime, timezone
from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash
from .models import Contract, ContractStatus, CreateContract
db = Database("ext_payroll")
# ---------------------------------------------------------------------------
# Contracts
# ---------------------------------------------------------------------------
async def create_contract(
data: CreateContract, employee_username: str = ""
) -> Contract:
contract = Contract(
**data.dict(),
id=urlsafe_short_hash()[:8],
employee_username=employee_username,
)
await db.insert("payroll.contracts", contract)
return contract
async def get_contract(contract_id: str) -> Contract | None:
return await db.fetchone(
"SELECT * FROM payroll.contracts WHERE id = :id",
{"id": contract_id},
Contract,
)
async def get_contracts() -> list[Contract]:
return await db.fetchall(
"SELECT * FROM payroll.contracts ORDER BY created_at DESC", model=Contract
)
async def get_contracts_by_status(status: ContractStatus) -> list[Contract]:
return await db.fetchall(
"SELECT * FROM payroll.contracts WHERE status = :status ORDER BY created_at",
{"status": status.value},
Contract,
)
async def get_contracts_for_employee(employee_id: str) -> list[Contract]:
return await db.fetchall(
"SELECT * FROM payroll.contracts WHERE employee_id = :eid "
"ORDER BY created_at DESC",
{"eid": employee_id},
Contract,
)
async def get_contracts_for_wallet(wallet_id: str) -> list[Contract]:
"""Contracts paying *into* a given wallet — the employee-side view, keyed
on the wallet whose key authenticated the request rather than on an
account id."""
return await db.fetchall(
"SELECT * FROM payroll.contracts WHERE employee_wallet = :wid "
"ORDER BY created_at DESC",
{"wid": wallet_id},
Contract,
)
async def update_contract(contract: Contract) -> Contract:
contract.updated_at = datetime.now(timezone.utc)
await db.update("payroll.contracts", contract)
return contract
async def delete_contract(contract_id: str) -> None:
await db.execute(
"DELETE FROM payroll.contracts WHERE id = :id", {"id": contract_id}
)

89
docs/data-model.md Normal file
View file

@ -0,0 +1,89 @@
# Payroll data model
## The one entity that matters
Everything hangs off a **contract**: a standing instruction to pay one
employee a fixed amount, at a fixed cadence, starting on a fixed date, a
fixed number of times.
```
contract
├─ who employee_id → employee_wallet (where the money lands)
├─ from source_wallet (where the money leaves)
├─ how much amount + currency (the agreed instruction)
└─ when start_date + frequency + total_periods
```
There is no separate "employee" table. An employee *is* an LNbits account;
duplicating account rows here would immediately drift from core. The only
thing copied out of core is `employee_username`, and that is display-only —
a label so a payroll history still reads sensibly after an account is
renamed or deleted. Authorisation and lookup always go through
`employee_id`.
## Schedule position
A contract stores `periods_done`, not a `next_run_at` timestamp.
Every payday is computed as `occurrence(start_date, frequency, n)` for
period index `n`, so the *n*-th payday depends only on the start date. Two
consequences worth the trade:
- **No drift.** Incrementally advancing a stored date accumulates error —
one late tick and every subsequent payday shifts. Recomputing from the
anchor cannot drift.
- **Month-end behaves.** A contract anchored on 31 Jan pays 28 Feb, then
**31** Mar — because March is computed from January, not from February.
Advancing month-by-month would have pinned it to the 28th forever.
`periods_done` counts periods **consumed**, which is paid *or* deliberately
skipped — not periods successfully paid. A failed payout leaves it alone,
which is exactly what makes the next tick retry the same payday instead of
quietly dropping it. "How many actually paid" is a question for the payout
ledger, not for this counter.
## Money: instruction vs. fact
| | lives on | means |
|---|---|---|
| `amount` + `currency` | contract | the **agreed instruction** — "€800 a month" |
| the sat figure paid | payout record | the **settled fact** for one period |
A fiat contract is converted to sats once per period, at payout time, by
LNbits' own invoice pricing. Whatever comes back is recorded as-is and is
canonical for that period. Nothing downstream recomputes it from
`amount × rate`: FX moves between quote and settlement, and rounding
accumulates over a year of paydays.
## Dates are civil dates
`start_date` is a `YYYY-MM-DD` string, not a DB date or timestamp. A payday
is a calendar fact ("the 31st"), not an instant. Storing it as a timestamp
invites a timezone normalisation to move a payday across a month boundary,
which for a monthly contract silently changes which month gets paid.
## Lifecycle
```
┌──────────┐
│ active │──────── periods exhausted ───────▶ completed
└────┬─────┘
│ ▲
pause │ │ resume
▼ │
┌──────────┐
│ paused │
└──────────┘
│
└──────────── cancel ─────────────────────▶ cancelled
```
`completed` and `cancelled` are terminal. `paused` keeps its schedule
position: resuming does not backfill the paydays that passed while paused,
because a pause is a decision not to pay them.
## Tables
`payroll.contracts` in `ext_payroll` (SQLite) or the `payroll` Postgres
schema. Indexed on `status` (the scheduler's hot path: "every contract
still eligible to pay") and on `employee_id` (the employee-facing view).

53
migrations.py Normal file
View file

@ -0,0 +1,53 @@
"""Payroll schema migrations.
This is an aiolabs-original extension, not a fork of an upstream LNbits one,
so there is no upstream `migrations.py` to stay byte-identical with and the
`migrations_fork.py` split does not apply here — ordinary migrations are
correct. They are still written so a re-run cannot wedge the schema: the
`dbversions` bookkeeping lives in the core DB while the tables live in
`ext_payroll`, and those two writes are not atomic, so a crash between them
replays the migration on the next boot.
Tables live in `ext_payroll.sqlite3` (SQLite) or the `payroll` Postgres
schema.
"""
async def m001_initial(db):
"""Payroll contracts — the standing "pay X this much, this often" rows."""
await db.execute(
f"""
CREATE TABLE payroll.contracts (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
employee_username TEXT NOT NULL DEFAULT '',
employee_wallet TEXT NOT NULL,
source_wallet TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'sat',
frequency TEXT NOT NULL DEFAULT 'monthly',
start_date TEXT NOT NULL,
total_periods INTEGER,
periods_done INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
backfill BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
# The scheduler's hot path is "every contract still eligible to pay".
# NOTE: the schema qualifier goes on the INDEX name, not the table —
# SQLite attaches the extension DB as schema `payroll`, and
# `CREATE INDEX ON schema.table` is a syntax error there.
await db.execute(
"CREATE INDEX payroll.idx_contracts_status ON contracts (status);"
)
# Employee-facing lookups ("show me my payroll lines") filter by account.
await db.execute(
"CREATE INDEX payroll.idx_contracts_employee ON contracts (employee_id);"
)

193
models.py Normal file
View file

@ -0,0 +1,193 @@
"""Payroll data model.
A *contract* is a standing instruction: "pay this employee this much, this
often, starting then, that many times". It is the only thing the operator
edits. Everything else about a payroll — when the next payment falls, how
many are left, what a period is actually worth in sats — is derived from
it, so there is exactly one row to get right.
Two money rules carried into the field definitions:
* `amount` + `currency` on a Contract are the agreed *instruction*, not a
settled fact. A EUR contract is stored in EUR forever; it is converted to
sats once per period, at payout time, by LNbits' own invoice pricing.
* The sat figure that comes back from that conversion is the canonical
*fact* for the period and gets recorded as-is on the payout. Nothing
downstream re-derives it from `amount * rate` — FX drifts between the
quote and the payment, and rounding accumulates. (Workspace rule:
source-of-truth, don't re-derive.)
Dates are stored as `YYYY-MM-DD` strings rather than DB dates: a payroll
period is a calendar fact ("the 31st"), not an instant, and keeping it a
plain civil date stops timezone normalisation from silently shifting a
payday across a month boundary.
"""
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field, validator
def _now() -> datetime:
return datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class Frequency(str, Enum):
"""How often a contract pays.
Split into two families because they clamp differently: the day-based
ones (daily/weekly/biweekly) are exact multiples and never need a
calendar; the month-based ones anchor on the start date's day-of-month
and clamp into short months. See services.occurrence_on.
"""
daily = "daily"
weekly = "weekly"
biweekly = "biweekly"
monthly = "monthly"
quarterly = "quarterly"
yearly = "yearly"
class ContractStatus(str, Enum):
active = "active" # eligible for payout on every scheduler tick
paused = "paused" # keeps its schedule position, pays nothing
completed = "completed" # ran out of periods; terminal
cancelled = "cancelled" # stopped by the operator; terminal
# Statuses that no longer move money and never will again.
TERMINAL_STATUSES = {ContractStatus.completed, ContractStatus.cancelled}
# ---------------------------------------------------------------------------
# Contract
# ---------------------------------------------------------------------------
class CreateContract(BaseModel):
"""What the super user supplies when setting up a payroll line."""
# Recipient. `employee_id` is an LNbits account id; `employee_wallet`
# must be one of that account's wallets (checked at the API boundary —
# nothing here can verify ownership on its own).
employee_id: str
employee_wallet: str
# Wallet the money leaves from. Not required to belong to the operator's
# own account, but in practice it is the treasury wallet.
source_wallet: str
amount: float
currency: str = "sat" # "sat" or an ISO-4217 code LNbits can price
frequency: Frequency = Frequency.monthly
start_date: str # YYYY-MM-DD — the first payday, and the anchor for all others
# None == open-ended: pays until paused or cancelled. A number means the
# contract completes itself after that many periods.
total_periods: int | None = None
label: str = "" # display name for the line, e.g. "Alice — dev retainer"
memo: str = "" # payment memo the employee sees; falls back to `label`
# Periods whose payday already passed before the contract existed. Off by
# default: creating a contract with a back-dated start is normally an
# anchoring choice ("we pay on the 1st"), not a request to fire six
# months of back-pay in one tick.
backfill: bool = False
@validator("start_date")
def _valid_date(cls, v: str) -> str:
datetime.strptime(v, "%Y-%m-%d") # raises for anything malformed
return v
@validator("amount")
def _positive_amount(cls, v: float) -> float:
if v <= 0:
raise ValueError("amount must be positive")
return v
@validator("total_periods")
def _positive_periods(cls, v: int | None) -> int | None:
if v is not None and v < 1:
raise ValueError("total_periods must be at least 1")
return v
class Contract(CreateContract):
id: str
# Display-only snapshot of the account's username at creation time, so a
# deleted or renamed account still renders a readable payroll history.
# Never used for authorisation or lookup — `employee_id` is.
employee_username: str = ""
status: ContractStatus = ContractStatus.active
# Periods *consumed* — paid or deliberately skipped. A failed payout does
# NOT advance this, which is what makes the next tick retry it rather
# than silently drop a payday.
periods_done: int = 0
created_at: datetime = Field(default_factory=_now)
updated_at: datetime = Field(default_factory=_now)
@property
def is_open_ended(self) -> bool:
return self.total_periods is None
@property
def periods_remaining(self) -> int | None:
if self.total_periods is None:
return None
return max(0, self.total_periods - self.periods_done)
class UpdateContract(BaseModel):
"""Partial edit of a contract. Only the terms an operator can sensibly
change mid-flight are here — the recipient, the source wallet and the
start date are the contract's identity and are not patchable, because
moving them would silently rewrite the meaning of payouts already made.
"""
amount: float | None = None
currency: str | None = None
frequency: Frequency | None = None
total_periods: int | None = None
label: str | None = None
memo: str | None = None
@validator("amount")
def _positive_amount(cls, v: float | None) -> float | None:
if v is not None and v <= 0:
raise ValueError("amount must be positive")
return v
# ---------------------------------------------------------------------------
# Account directory (read model for the operator's user picker)
# ---------------------------------------------------------------------------
class DirectoryWallet(BaseModel):
id: str
name: str
balance_msat: int = 0
class DirectoryUser(BaseModel):
"""One row of the "select a user" dropdown. Deliberately narrow: an id,
something human-readable, and the wallets a payout could land in. No
keys, no password state, nothing the picker does not need."""
id: str
username: str | None = None
email: str | None = None
wallets: list[DirectoryWallet] = Field(default_factory=list)
@property
def display_name(self) -> str:
return self.username or self.email or self.id