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:
Padreug 2026-08-31 21:42:00 +02:00
commit 18a17cd693
7 changed files with 345 additions and 12 deletions

View file

@ -33,7 +33,9 @@ from .models import (
Frequency,
Payout,
PayoutStatus,
PricingMode,
)
from .rates import historical_btc_rate, sats_for
# Frequencies that are an exact number of days: no calendar involved, so no
# clamping is possible or needed.
@ -187,6 +189,8 @@ class PeriodOutcome:
detail: str = ""
amount_msat: int | None = None
payment_hash: str | None = None
rate: float | None = None
rate_source: str = ""
@property
def consumed(self) -> bool:
@ -214,12 +218,86 @@ def _lock_for(contract_id: str) -> asyncio.Lock:
return lock
def _today() -> date:
return datetime.now(timezone.utc).date()
def payout_memo(contract: Contract, payday: date) -> str:
label = contract.memo or contract.label or "Payroll"
return f"{label} — {payday.isoformat()}"
async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutcome:
class PricingError(ValueError):
"""A period's sat amount could not be established.
Deliberately fatal for that period. The alternative — falling back to
today's rate when the payday's rate is unavailable — pays a materially
different amount than intended and hides it, which is exactly the class
of error nobody finds until an audit.
"""
@dataclass
class Price:
"""What to hand `create_invoice`, plus the provenance to record.
When `currency` is the contract's own, LNbits does the conversion and
`rate` is filled in afterwards from the invoice it priced — read back
rather than recomputed, so the ledger records what actually happened.
"""
amount: float
currency: str
rate: float | None
source: str
async def resolve_price(
contract: Contract,
payday: date,
today: date,
mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> Price:
"""Decide what a period is worth, and say where the number came from.
`mode`/`manual_rate` override the contract's own settings for a single
call, which is how an operator prices one off-cycle payout differently
without editing the contract.
"""
if contract.currency.lower() == "sat":
return Price(contract.amount, "sat", None, "")
mode = mode or contract.pricing_mode
manual_rate = manual_rate or contract.manual_rate
# A payday that is today or ahead needs no history: every mode agrees,
# and LNbits' own live pricing is the freshest source available.
if payday >= today or mode == PricingMode.current:
return Price(contract.amount, contract.currency, None, "current")
if mode == PricingMode.manual:
if not manual_rate:
raise PricingError("manual pricing selected but no rate given")
return Price(
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
)
rate = await historical_btc_rate(payday, contract.currency)
if rate is None:
raise PricingError(
f"no historical {contract.currency} rate available for {payday}"
)
return Price(sats_for(contract.amount, rate), "sat", rate, "payday")
async def pay_period(
contract: Contract,
index: int,
payday: date,
mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> PeriodOutcome:
"""Move one period's money from the source wallet to the employee wallet.
An internal invoice on the destination wallet, paid from the source
@ -240,11 +318,23 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
"payday": payday.isoformat(),
}
try:
price = await resolve_price(contract, payday, _today(), mode, manual_rate)
except (PricingError, ValueError) as exc:
return PeriodOutcome(
index=index,
payday=payday,
status=PayoutStatus.failed,
detail=str(exc),
)
if price.source:
extra["rate_source"] = price.source
try:
invoice = await create_invoice(
wallet_id=contract.employee_wallet,
amount=contract.amount,
currency=contract.currency,
amount=price.amount,
currency=price.currency,
memo=memo,
internal=True,
extra=extra,
@ -262,6 +352,13 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
# invoice.amount is msat and is now the canonical figure for this period.
amount_msat = invoice.amount
# For a live conversion LNbits stashes the rate it used on the payment;
# read it back rather than recomputing, so the ledger records the number
# that was actually applied.
rate = price.rate
if rate is None and price.source == "current":
rate = (invoice.extra or {}).get("btc_rate")
source = await get_wallet(contract.source_wallet)
if not source:
return PeriodOutcome(
@ -270,6 +367,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
status=PayoutStatus.failed,
detail="source wallet not found",
amount_msat=amount_msat,
rate=rate,
rate_source=price.source,
)
if source.balance_msat < amount_msat:
return PeriodOutcome(
@ -282,6 +381,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
f"{amount_msat // 1000} sat required"
),
amount_msat=amount_msat,
rate=rate,
rate_source=price.source,
)
try:
@ -300,6 +401,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
detail=f"payment failed: {exc}",
amount_msat=amount_msat,
payment_hash=invoice.payment_hash,
rate=rate,
rate_source=price.source,
)
return PeriodOutcome(
@ -308,6 +411,8 @@ async def pay_period(contract: Contract, index: int, payday: date) -> PeriodOutc
status=PayoutStatus.paid,
amount_msat=amount_msat,
payment_hash=invoice.payment_hash,
rate=rate,
rate_source=price.source,
)
@ -324,7 +429,7 @@ async def run_due_periods(
retried on the next tick, because a failed period does not advance the
contract's position.
"""
today = today or datetime.now(timezone.utc).date()
today = today or _today()
async with _lock_for(contract.id):
# Re-read under the lock: a manual run may have moved the position
@ -386,6 +491,8 @@ async def _record(contract: Contract, outcome: PeriodOutcome) -> Payout:
source_wallet=contract.source_wallet,
payment_hash=outcome.payment_hash,
detail=outcome.detail,
rate=outcome.rate,
rate_source=outcome.rate_source,
)
)
@ -522,7 +629,7 @@ def resume(
contract.status = ContractStatus.active
if not catch_up:
today = today or datetime.now(timezone.utc).date()
today = today or _today()
skipped_to = fast_forward_index(contract, today)
if skipped_to != contract.periods_done:
logger.info(
@ -551,7 +658,11 @@ def cancel(contract: Contract) -> Contract:
# ---------------------------------------------------------------------------
async def pay_now(contract: Contract) -> Payout:
async def pay_now(
contract: Contract,
mode: PricingMode | None = None,
manual_rate: float | None = None,
) -> Payout:
"""Settle the contract's next period immediately, whatever the calendar
says.
@ -565,6 +676,10 @@ async def pay_now(contract: Contract) -> Payout:
because `_settle`'s back-dated skip exists to stop a *new* contract
firing surprise back-pay. An operator explicitly asking to pay a period
is not a surprise.
`mode`/`manual_rate` price this one payout differently without editing
the contract — the case an operator hits when entering a payment that
happened weeks ago at a rate they already know.
"""
async with _lock_for(contract.id):
fresh = await crud.get_contract(contract.id)
@ -582,9 +697,9 @@ async def pay_now(contract: Contract) -> Payout:
index = contract.periods_done
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
today = datetime.now(timezone.utc).date()
today = _today()
outcome = await pay_period(contract, index, payday)
outcome = await pay_period(contract, index, payday, mode, manual_rate)
if payday > today and outcome.status == PayoutStatus.paid:
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"