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

131
tests/test_pricing.py Normal file
View file

@ -0,0 +1,131 @@
"""Pricing a period.
`resolve_price` is where a back-dated payout stops being worth today's
money. These pin down which mode wins when, and that an unavailable rate
refuses rather than substitutes.
"""
import asyncio
from datetime import date
import pytest
from .. import services
from ..models import PricingMode
from ..services import PricingError, resolve_price
from .conftest import make_contract
TODAY = date(2026, 8, 31)
PAST = date(2026, 5, 15)
PAST_RATE = 69743.26
@pytest.fixture
def historical(monkeypatch):
"""Stub the lookup and record what it was asked for."""
asked = []
async def fake_rate(day, currency):
asked.append((day, currency))
return PAST_RATE if currency == "EUR" else None
monkeypatch.setattr(services, "historical_btc_rate", fake_rate)
return asked
def eur(**kw):
return make_contract(currency="EUR", amount=800, **kw)
def price(contract, payday=PAST, today=TODAY, **kw):
return asyncio.run(resolve_price(contract, payday, today, **kw))
# --- when history is irrelevant --------------------------------------------
def test_a_payday_today_uses_live_pricing(historical):
"""No mode needs history for a payday that has not passed — LNbits' own
conversion is the freshest source there is."""
p = price(eur(), payday=TODAY)
assert (p.currency, p.source) == ("EUR", "current")
assert p.amount == 800 # handed to create_invoice as fiat, unconverted
assert historical == []
def test_a_future_payday_uses_live_pricing(historical):
p = price(eur(), payday=date(2026, 12, 1))
assert p.source == "current"
assert historical == []
def test_a_sat_contract_never_prices_at_all(historical):
p = price(make_contract(currency="sat", amount=1000))
assert (p.amount, p.currency, p.rate, p.source) == (1000, "sat", None, "")
assert historical == []
# --- the three modes -------------------------------------------------------
def test_payday_mode_converts_at_the_paydays_rate(historical):
p = price(eur(pricing_mode=PricingMode.payday))
assert p.source == "payday"
assert p.currency == "sat"
assert p.rate == PAST_RATE
assert p.amount == round(800 / PAST_RATE * 1e8)
assert historical == [(PAST, "EUR")]
def test_current_mode_ignores_the_past_payday(historical):
"""The employee is owed EUR 800 and gets EUR 800 of bitcoin today."""
p = price(eur(pricing_mode=PricingMode.current))
assert (p.currency, p.source) == ("EUR", "current")
assert historical == []
def test_manual_mode_uses_the_stated_rate(historical):
p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000))
assert p.source == "manual"
assert p.rate == 100_000
assert p.amount == 800_000 # 800 EUR at 100k EUR/BTC == 0.008 BTC
assert historical == []
def test_manual_mode_without_a_rate_refuses(historical):
with pytest.raises(PricingError):
price(eur(pricing_mode=PricingMode.manual))
# --- per-call override -----------------------------------------------------
def test_an_override_beats_the_contract(historical):
"""Pricing one off-cycle payout differently must not require editing the
contract."""
contract = eur(pricing_mode=PricingMode.payday)
p = price(contract, mode=PricingMode.manual, manual_rate=50_000)
assert (p.source, p.rate) == ("manual", 50_000)
assert historical == []
def test_an_override_rate_supplies_a_contract_that_has_none(historical):
p = price(eur(), mode=PricingMode.manual, manual_rate=50_000)
assert p.amount == round(800 / 50_000 * 1e8)
# --- the refusal that matters ----------------------------------------------
def test_an_unavailable_rate_refuses_rather_than_substituting(historical):
"""The whole point: no silent fallback to today's rate. A rate that
moved 30% since the payday would otherwise pay 30% off, discovered only
on review."""
with pytest.raises(PricingError):
price(make_contract(currency="JPY", amount=800))
def test_the_refusal_names_the_date_and_currency(historical):
with pytest.raises(PricingError) as exc:
price(make_contract(currency="JPY", amount=800))
assert "JPY" in str(exc.value) and "2026-05-15" in str(exc.value)