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

@ -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: