diff --git a/docs/operations.md b/docs/operations.md index b3a7869..2c816db 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/services.py b/services.py index f1123a0..60e8950 100644 --- a/services.py +++ b/services.py @@ -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: diff --git a/tests/conftest.py b/tests/conftest.py index 99fc158..f4e5b34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 ), diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 8c5c908..31eab99 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -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"