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:
parent
a211a8ab8b
commit
f253b51aa9
4 changed files with 424 additions and 0 deletions
89
crud.py
Normal file
89
crud.py
Normal 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
89
docs/data-model.md
Normal 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
53
migrations.py
Normal 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
193
models.py
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue