Found by tracing what happens when a back-dated backfill contract cannot fetch a historical rate. The failure handling itself was fine — period 0 fails, the backlog halts so nothing settles out of order, five ledger rows record the reason, no money moves, and the contract auto-pauses once the retry budget is spent. The recovery was not. The operator fixes the cause (switches to a stated rate, or to current), clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently written off, contract back to looking healthy, employee never paid. The confirm dialog even asserted the missed paydays "are written off" — true of one kind of pause and a lie about the other. Two features colliding. "Do not backfill a deliberate pause" is right when the operator paused: the pause *was* the decision not to pay. It is wrong when payroll paused, because nobody decided anything — the money is still owed and the operator has just removed whatever blocked it. Contracts now carry `paused_reason`, set only when payroll pauses them and cleared by a deliberate pause. Resume infers from it, and an explicit `catch_up` still overrides either way. The console asks a different question for each, quoting the reason, and flags a payroll-paused contract in the table so the distinction is visible before anyone clicks. Verified end to end: five failing ticks leave periods_done at 0 and pause with "period 0 (2026-08-01) failed 5 times: no historical EUR rate available for 2026-08-01"; resuming after switching to a manual rate keeps the position at 0, and the next tick settles all seven owed periods. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
332 lines
12 KiB
Python
332 lines
12 KiB
Python
"""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 PricingMode(str, Enum):
|
|
"""How a period's fiat amount becomes a sat amount.
|
|
|
|
Only ever matters for a payday in the past — for a payday that is today
|
|
or ahead, all three agree and LNbits' own live pricing is used.
|
|
"""
|
|
|
|
# Convert at what BTC was worth on the payday itself. The default: a
|
|
# back-dated period is priced as of when it was due, not when somebody
|
|
# got round to paying it.
|
|
payday = "payday"
|
|
# Convert at today's rate. Correct when the obligation is understood as
|
|
# "we owe them EUR 800, whenever it settles".
|
|
current = "current"
|
|
# Convert at a rate the operator states outright (e.g. 100000 EUR/BTC),
|
|
# for a figure that was agreed rather than looked up.
|
|
manual = "manual"
|
|
|
|
|
|
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
|
|
|
|
# How to price a back-dated period. Irrelevant for a sat-denominated
|
|
# contract, and irrelevant for any payday that is not in the past.
|
|
pricing_mode: PricingMode = PricingMode.payday
|
|
# Units of `currency` per whole BTC. Required by, and only read under,
|
|
# PricingMode.manual.
|
|
manual_rate: float | None = None
|
|
|
|
@validator("manual_rate")
|
|
def _positive_rate(cls, v: float | None) -> float | None:
|
|
if v is not None and v <= 0:
|
|
raise ValueError("manual_rate must be positive")
|
|
return v
|
|
|
|
@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
|
|
# Why the contract is paused, when payroll paused it rather than the
|
|
# operator. Empty for a deliberate pause. The distinction matters on
|
|
# resume: a deliberate pause means "do not pay those paydays", while an
|
|
# automatic one means "could not pay them yet" — opposite intentions
|
|
# that must not share a recovery path.
|
|
paused_reason: str = ""
|
|
# 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
|
|
pricing_mode: PricingMode | None = None
|
|
manual_rate: float | 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = ""
|
|
|
|
# Units of `currency` per whole BTC actually used for this period, and
|
|
# which of the pricing modes produced it. For a live conversion the rate
|
|
# is read back off the invoice LNbits priced rather than recomputed, so
|
|
# the row records what happened and not an approximation of it. Null on
|
|
# a sat contract, and on a failure that never got as far as pricing.
|
|
rate: float | None = None
|
|
rate_source: str = "" # "current" | "payday" | "manual" | "" (sat / none)
|
|
|
|
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
|