feat: name the applied rate in the payout memo

A back-dated payout's sat figure is unexplainable on its own — employee_2's
August backfill paid 18,353 sat and 14,794 sat for the same ten euro, and
only the rate says why. Both wallets now show it:

    dev retainer — 2026-08-01 · 10 EUR @ 54,487 EUR/BTC
    dev retainer — 2026-08-29 · 10 EUR @ 67,596.6 EUR/BTC

This required moving the `current` mode conversion into payroll as well.
Previously that mode handed create_invoice a fiat amount and let LNbits
convert, so the rate only became knowable by reading it back off the
resulting invoice — after the memo had already been fixed. Now every mode
resolves its rate before invoicing and the memo is accurate in all three,
rather than only for the back-dated ones.

It remains a single conversion, not a second opinion: fiat_amount_as_satoshis
is the same function create_invoice would have called, and the rate is
derived by the same formula calculate_fiat_amounts uses. Because passing
sats means LNbits no longer stamps the fiat metadata itself, payroll now
writes the identical fiat_currency / fiat_amount / fiat_rate / btc_rate keys
onto the payment, so a payroll payment still reads like any other
fiat-priced one in the payments list.

A rate that rounds an amount to zero sats now fails the period rather than
raising a zero-division while deriving the rate.

Memo omits the rate clause for sat-denominated contracts, where there is no
conversion to report.

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:55:49 +02:00
commit 3b14db3dff
4 changed files with 121 additions and 27 deletions

View file

@ -20,16 +20,23 @@ PAST = date(2026, 5, 15)
PAST_RATE = 69743.26
LIVE_RATE = 67_000.0
@pytest.fixture
def historical(monkeypatch):
"""Stub the lookup and record what it was asked for."""
"""Stub both rate sources and record what the historical one was asked."""
asked = []
async def fake_rate(day, currency):
asked.append((day, currency))
return PAST_RATE if currency == "EUR" else None
async def fake_live(amount, _currency):
return round(amount / LIVE_RATE * 1e8)
monkeypatch.setattr(services, "historical_btc_rate", fake_rate)
monkeypatch.setattr(services, "fiat_amount_as_satoshis", fake_live)
return asked
@ -44,16 +51,22 @@ def price(contract, payday=PAST, today=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."""
def test_a_payday_today_uses_the_live_rate(historical):
"""No mode needs history for a payday that has not passed."""
p = price(eur(), payday=TODAY)
assert (p.currency, p.source) == ("EUR", "current")
assert p.amount == 800 # handed to create_invoice as fiat, unconverted
assert (p.currency, p.source) == ("sat", "current")
assert p.amount == round(800 / LIVE_RATE * 1e8)
assert historical == []
def test_a_future_payday_uses_live_pricing(historical):
def test_the_live_rate_is_known_before_the_invoice(historical):
"""Payroll converts rather than handing create_invoice a fiat amount, so
the rate exists in time to go in the memo."""
p = price(eur(), payday=TODAY)
assert p.rate == pytest.approx(LIVE_RATE, rel=1e-6)
def test_a_future_payday_uses_the_live_rate(historical):
p = price(eur(), payday=date(2026, 12, 1))
assert p.source == "current"
assert historical == []
@ -80,7 +93,8 @@ def test_payday_mode_converts_at_the_paydays_rate(historical):
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 p.source == "current"
assert p.amount == round(800 / LIVE_RATE * 1e8)
assert historical == []
@ -129,3 +143,28 @@ 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)
# --- memo ------------------------------------------------------------------
def test_memo_names_the_rate_applied():
"""18,353 sat and 14,794 sat are both "ten euro" — only the rate says
why a back-dated payout differs from today's."""
memo = services.payout_memo(eur(label="dev retainer"), PAST, PAST_RATE)
assert memo == "dev retainer — 2026-05-15 · 800 EUR @ 69,743.26 EUR/BTC"
def test_memo_prefers_the_payment_memo_over_the_label():
contract = eur(label="internal label")
contract.memo = "Salary"
assert services.payout_memo(contract, PAST, PAST_RATE).startswith("Salary — ")
def test_memo_of_a_sat_contract_has_no_rate():
contract = make_contract(currency="sat", amount=1000, label="chores")
assert services.payout_memo(contract, PAST) == "chores — 2026-05-15"
def test_memo_without_a_rate_still_states_the_fiat_amount():
assert services.payout_memo(eur(label="x"), PAST) == "x — 2026-05-15 · 800 EUR"