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
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue