feat: price a back-dated period at the day it was due
Until now every payout converted at whatever the rate was when it settled, so a period paid late was silently mispriced. Contracts now carry a pricing mode, and every payout records the rate it used plus where that rate came from — a figure in the ledger can be explained months later instead of merely trusted. Three modes, per contract and overridable per payout: - `payday` (default) converts at what BTC was worth on the payday itself, via the historical lookup. - `current` converts at today's rate — correct when the obligation reads "we owe EUR 800 whenever it settles". - `manual` converts at a rate the operator states (100000 EUR/BTC), for a figure that was agreed rather than looked up. For a payday that is today or ahead, all three collapse to the same thing and none of them touches the network: LNbits' own live pricing is the freshest source available, so `resolve_price` returns the fiat amount unconverted and lets create_invoice do its job. History is consulted only where it can actually change the answer. Where payroll does convert, it must hand create_invoice a sat amount — create_invoice always prices fiat itself and cannot be told a rate. That moves the single conversion point into payroll, which is why the rate and its source are recorded on the payout. In `current` mode the rate is read back off the invoice LNbits priced (extra["btc_rate"]) rather than recomputed, so the row records the number actually applied. An unavailable rate raises PricingError and fails the period. Deliberately no fallback to today's rate: a rate that moved 30% since the payday would pay 30% off and hide it, which is the class of error nobody finds until an audit. The existing retry-then-pause machinery already handles a failed period, and the ledger row names the date and currency that could not be priced. Manual mode missing its rate is caught at contract-creation time instead, rather than surfacing as a failed payout weeks later. m003 defaults preserve behaviour for anything in flight — every live payday is today or ahead, where the modes agree. Verified by applying m003 to a copy of the running instance's database: the existing contract and its paid payout both survive and read back correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
aed31f4b70
commit
18a17cd693
7 changed files with 345 additions and 12 deletions
42
models.py
42
models.py
|
|
@ -55,6 +55,25 @@ class Frequency(str, Enum):
|
|||
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
|
||||
|
|
@ -101,6 +120,19 @@ class CreateContract(BaseModel):
|
|||
# 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
|
||||
|
|
@ -159,6 +191,8 @@ class UpdateContract(BaseModel):
|
|||
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:
|
||||
|
|
@ -239,6 +273,14 @@ class Payout(BaseModel):
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue