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
131
tests/test_pricing.py
Normal file
131
tests/test_pricing.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue