diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..71c98a6 --- /dev/null +++ b/crud.py @@ -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} + ) diff --git a/docs/data-model.md b/docs/data-model.md new file mode 100644 index 0000000..faee799 --- /dev/null +++ b/docs/data-model.md @@ -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). diff --git a/migrations.py b/migrations.py new file mode 100644 index 0000000..547b587 --- /dev/null +++ b/migrations.py @@ -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);" + ) diff --git a/models.py b/models.py new file mode 100644 index 0000000..782eaf9 --- /dev/null +++ b/models.py @@ -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