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

@ -57,7 +57,7 @@ def payroll_stub(monkeypatch):
async def fake_count_period_failures(_contract_id, _period_index):
return stub.prior_failures
async def fake_pay_period(contract, index, payday):
async def fake_pay_period(contract, index, payday, mode=None, manual_rate=None):
stub.attempted.append(index)
paid = stub.outcome_status == PayoutStatus.paid
return PeriodOutcome(