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

@ -84,10 +84,24 @@ The two sources disagree by a couple of percent on the same date (for
close versus a cross-exchange average). That is why the ledger records
`rate_source` beside `rate` instead of presenting a bare figure as canonical.
Where payroll converts, it hands `create_invoice` a **sat** amount, because
`create_invoice` always prices fiat itself and cannot be told a rate. The
rate used and its source are recorded on the payout row either way — in
`current` mode read back off the invoice LNbits priced, not recomputed.
Payroll does the conversion itself in **every** mode and hands
`create_invoice` a sat amount. Not because it has to for `current` mode, but
because knowing the rate *before* the invoice exists is what lets the memo
name it. It stays a single conversion — `fiat_amount_as_satoshis` is the
function `create_invoice` would have called — and payroll stamps the same
`fiat_currency` / `fiat_amount` / `fiat_rate` / `btc_rate` keys onto the
payment that `calculate_fiat_amounts` would have, so a payroll payment reads
like any other fiat-priced one.
Both wallets therefore show a memo naming the rate applied:
```
dev retainer — 2026-08-01 · 10 EUR @ 54,487 EUR/BTC
dev retainer — 2026-08-29 · 10 EUR @ 67,596.6 EUR/BTC
```
Which is the point: 18,353 sat and 14,794 sat are both "ten euro", and only
the rate says why they differ.
**A rate that cannot be established fails the period.** There is deliberately
no fallback to today's rate: one that moved 30% since the payday would pay

View file

@ -23,6 +23,7 @@ from datetime import date, datetime, timedelta, timezone
from lnbits.core.crud import get_wallet
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.helpers import urlsafe_short_hash
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
from loguru import logger
from . import crud
@ -222,9 +223,27 @@ def _today() -> date:
return datetime.now(timezone.utc).date()
def payout_memo(contract: Contract, payday: date) -> str:
def _fmt(value: float) -> str:
"""Trim a float for display without lying about precision."""
return f"{value:,.2f}".rstrip("0").rstrip(".")
def payout_memo(contract: Contract, payday: date, rate: float | None = None) -> str:
"""What both wallets show for this payout.
Names the rate applied, because for a back-dated period the sat figure
on its own is unexplainable — 18,353 sat and 14,794 sat are both "ten
euro", and only the rate says why they differ. The rate is always the
one payroll actually converted at, never a lookup made for display.
"""
label = contract.memo or contract.label or "Payroll"
return f"{label} — {payday.isoformat()}"
parts = [f"{label} — {payday.isoformat()}"]
if contract.currency.lower() != "sat":
money = f"{_fmt(contract.amount)} {contract.currency}"
if rate:
money += f" @ {_fmt(rate)} {contract.currency}/BTC"
parts.append(money)
return " · ".join(parts)
class PricingError(ValueError):
@ -271,10 +290,21 @@ async def resolve_price(
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.
# A payday that is today or ahead needs no history: every mode agrees on
# the live rate. Payroll still does the conversion rather than handing
# create_invoice a fiat amount, because knowing the rate *before* the
# invoice exists is what lets the memo state it. It is the same single
# conversion either way — fiat_amount_as_satoshis is the function
# create_invoice would have called — not a second opinion.
if payday >= today or mode == PricingMode.current:
return Price(contract.amount, contract.currency, None, "current")
amount_sat = await fiat_amount_as_satoshis(contract.amount, contract.currency)
if amount_sat <= 0:
raise PricingError(
f"{contract.amount} {contract.currency} rounds to zero sats"
)
# Derived exactly as lnbits.core.services.calculate_fiat_amounts does.
rate = (contract.amount / amount_sat) * 100_000_000
return Price(amount_sat, "sat", rate, "current")
if mode == PricingMode.manual:
if not manual_rate:
@ -283,12 +313,12 @@ async def resolve_price(
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
)
rate = await historical_btc_rate(payday, contract.currency)
if rate is None:
historical = await historical_btc_rate(payday, contract.currency)
if historical is None:
raise PricingError(
f"no historical {contract.currency} rate available for {payday}"
)
return Price(sats_for(contract.amount, rate), "sat", rate, "payday")
return Price(sats_for(contract.amount, historical), "sat", historical, "payday")
async def pay_period(
@ -310,7 +340,6 @@ async def pay_period(
never settled — and it is the price of not pre-computing the sat amount
a second time just to run a balance check.
"""
memo = payout_memo(contract, payday)
extra = {
"tag": "payroll",
"contract_id": contract.id,
@ -325,10 +354,26 @@ async def pay_period(
index=index,
payday=payday,
status=PayoutStatus.failed,
# The memo cannot be built without a rate, so name the contract
# plainly in the failure instead.
detail=str(exc),
)
memo = payout_memo(contract, payday, price.rate)
if price.source:
extra["rate_source"] = price.source
if price.rate:
# Payroll converted, so create_invoice is handed sats and will not
# stamp these itself. Same keys and formulas as calculate_fiat_amounts,
# so a payroll payment reads like any other fiat-priced one.
extra.update(
{
"fiat_currency": contract.currency,
"fiat_amount": round(contract.amount, 3),
"fiat_rate": price.amount / contract.amount,
"btc_rate": price.rate,
}
)
try:
invoice = await create_invoice(
@ -351,13 +396,7 @@ async def pay_period(
# 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:

View file

@ -24,6 +24,7 @@ def make_contract(
created_at: str = "2026-01-01",
pricing_mode: PricingMode = PricingMode.payday,
manual_rate: float | None = None,
label: str = "",
) -> Contract:
return Contract(
id=contract_id,
@ -41,6 +42,7 @@ def make_contract(
backfill=backfill,
pricing_mode=pricing_mode,
manual_rate=manual_rate,
label=label,
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
tzinfo=timezone.utc
),

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"