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
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