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:
parent
2e6009b612
commit
3b14db3dff
4 changed files with 121 additions and 27 deletions
|
|
@ -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
|
close versus a cross-exchange average). That is why the ledger records
|
||||||
`rate_source` beside `rate` instead of presenting a bare figure as canonical.
|
`rate_source` beside `rate` instead of presenting a bare figure as canonical.
|
||||||
|
|
||||||
Where payroll converts, it hands `create_invoice` a **sat** amount, because
|
Payroll does the conversion itself in **every** mode and hands
|
||||||
`create_invoice` always prices fiat itself and cannot be told a rate. The
|
`create_invoice` a sat amount. Not because it has to for `current` mode, but
|
||||||
rate used and its source are recorded on the payout row either way — in
|
because knowing the rate *before* the invoice exists is what lets the memo
|
||||||
`current` mode read back off the invoice LNbits priced, not recomputed.
|
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
|
**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
|
no fallback to today's rate: one that moved 30% since the payday would pay
|
||||||
|
|
|
||||||
69
services.py
69
services.py
|
|
@ -23,6 +23,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||||
from lnbits.core.crud import get_wallet
|
from lnbits.core.crud import get_wallet
|
||||||
from lnbits.core.services import create_invoice, pay_invoice
|
from lnbits.core.services import create_invoice, pay_invoice
|
||||||
from lnbits.helpers import urlsafe_short_hash
|
from lnbits.helpers import urlsafe_short_hash
|
||||||
|
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from . import crud
|
from . import crud
|
||||||
|
|
@ -222,9 +223,27 @@ def _today() -> date:
|
||||||
return datetime.now(timezone.utc).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"
|
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):
|
class PricingError(ValueError):
|
||||||
|
|
@ -271,10 +290,21 @@ async def resolve_price(
|
||||||
mode = mode or contract.pricing_mode
|
mode = mode or contract.pricing_mode
|
||||||
manual_rate = manual_rate or contract.manual_rate
|
manual_rate = manual_rate or contract.manual_rate
|
||||||
|
|
||||||
# A payday that is today or ahead needs no history: every mode agrees,
|
# A payday that is today or ahead needs no history: every mode agrees on
|
||||||
# and LNbits' own live pricing is the freshest source available.
|
# 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:
|
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 mode == PricingMode.manual:
|
||||||
if not manual_rate:
|
if not manual_rate:
|
||||||
|
|
@ -283,12 +313,12 @@ async def resolve_price(
|
||||||
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
|
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
|
||||||
)
|
)
|
||||||
|
|
||||||
rate = await historical_btc_rate(payday, contract.currency)
|
historical = await historical_btc_rate(payday, contract.currency)
|
||||||
if rate is None:
|
if historical is None:
|
||||||
raise PricingError(
|
raise PricingError(
|
||||||
f"no historical {contract.currency} rate available for {payday}"
|
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(
|
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
|
never settled — and it is the price of not pre-computing the sat amount
|
||||||
a second time just to run a balance check.
|
a second time just to run a balance check.
|
||||||
"""
|
"""
|
||||||
memo = payout_memo(contract, payday)
|
|
||||||
extra = {
|
extra = {
|
||||||
"tag": "payroll",
|
"tag": "payroll",
|
||||||
"contract_id": contract.id,
|
"contract_id": contract.id,
|
||||||
|
|
@ -325,10 +354,26 @@ async def pay_period(
|
||||||
index=index,
|
index=index,
|
||||||
payday=payday,
|
payday=payday,
|
||||||
status=PayoutStatus.failed,
|
status=PayoutStatus.failed,
|
||||||
|
# The memo cannot be built without a rate, so name the contract
|
||||||
|
# plainly in the failure instead.
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
memo = payout_memo(contract, payday, price.rate)
|
||||||
if price.source:
|
if price.source:
|
||||||
extra["rate_source"] = 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:
|
try:
|
||||||
invoice = await create_invoice(
|
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.
|
# invoice.amount is msat and is now the canonical figure for this period.
|
||||||
amount_msat = invoice.amount
|
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
|
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)
|
source = await get_wallet(contract.source_wallet)
|
||||||
if not source:
|
if not source:
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ def make_contract(
|
||||||
created_at: str = "2026-01-01",
|
created_at: str = "2026-01-01",
|
||||||
pricing_mode: PricingMode = PricingMode.payday,
|
pricing_mode: PricingMode = PricingMode.payday,
|
||||||
manual_rate: float | None = None,
|
manual_rate: float | None = None,
|
||||||
|
label: str = "",
|
||||||
) -> Contract:
|
) -> Contract:
|
||||||
return Contract(
|
return Contract(
|
||||||
id=contract_id,
|
id=contract_id,
|
||||||
|
|
@ -41,6 +42,7 @@ def make_contract(
|
||||||
backfill=backfill,
|
backfill=backfill,
|
||||||
pricing_mode=pricing_mode,
|
pricing_mode=pricing_mode,
|
||||||
manual_rate=manual_rate,
|
manual_rate=manual_rate,
|
||||||
|
label=label,
|
||||||
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
|
created_at=datetime.strptime(created_at, "%Y-%m-%d").replace(
|
||||||
tzinfo=timezone.utc
|
tzinfo=timezone.utc
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -20,16 +20,23 @@ PAST = date(2026, 5, 15)
|
||||||
PAST_RATE = 69743.26
|
PAST_RATE = 69743.26
|
||||||
|
|
||||||
|
|
||||||
|
LIVE_RATE = 67_000.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def historical(monkeypatch):
|
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 = []
|
asked = []
|
||||||
|
|
||||||
async def fake_rate(day, currency):
|
async def fake_rate(day, currency):
|
||||||
asked.append((day, currency))
|
asked.append((day, currency))
|
||||||
return PAST_RATE if currency == "EUR" else None
|
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, "historical_btc_rate", fake_rate)
|
||||||
|
monkeypatch.setattr(services, "fiat_amount_as_satoshis", fake_live)
|
||||||
return asked
|
return asked
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,16 +51,22 @@ def price(contract, payday=PAST, today=TODAY, **kw):
|
||||||
# --- when history is irrelevant --------------------------------------------
|
# --- when history is irrelevant --------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_a_payday_today_uses_live_pricing(historical):
|
def test_a_payday_today_uses_the_live_rate(historical):
|
||||||
"""No mode needs history for a payday that has not passed — LNbits' own
|
"""No mode needs history for a payday that has not passed."""
|
||||||
conversion is the freshest source there is."""
|
|
||||||
p = price(eur(), payday=TODAY)
|
p = price(eur(), payday=TODAY)
|
||||||
assert (p.currency, p.source) == ("EUR", "current")
|
assert (p.currency, p.source) == ("sat", "current")
|
||||||
assert p.amount == 800 # handed to create_invoice as fiat, unconverted
|
assert p.amount == round(800 / LIVE_RATE * 1e8)
|
||||||
assert historical == []
|
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))
|
p = price(eur(), payday=date(2026, 12, 1))
|
||||||
assert p.source == "current"
|
assert p.source == "current"
|
||||||
assert historical == []
|
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):
|
def test_current_mode_ignores_the_past_payday(historical):
|
||||||
"""The employee is owed EUR 800 and gets EUR 800 of bitcoin today."""
|
"""The employee is owed EUR 800 and gets EUR 800 of bitcoin today."""
|
||||||
p = price(eur(pricing_mode=PricingMode.current))
|
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 == []
|
assert historical == []
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,3 +143,28 @@ def test_the_refusal_names_the_date_and_currency(historical):
|
||||||
with pytest.raises(PricingError) as exc:
|
with pytest.raises(PricingError) as exc:
|
||||||
price(make_contract(currency="JPY", amount=800))
|
price(make_contract(currency="JPY", amount=800))
|
||||||
assert "JPY" in str(exc.value) and "2026-05-15" in str(exc.value)
|
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue