payroll/models.py
Padreug e77f431d47 fix: resuming an auto-paused contract no longer discards the backlog
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
2026-08-31 23:02:45 +02:00

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