Pay-now on a period due next week offered "Rate on the payday" and then
found a rate anyway, with only a passive hint to explain why. Two separate
problems behind that.
The real one: `resolve_price` tested `payday >= today` before it tested the
mode, so `manual` was silently ignored for any period not already
back-dated. "Pay at the rate we agreed" quietly did not, and a contract
pegged to a fixed rate honoured it only on late periods. A stated rate is
an instruction rather than a lookup, so it now wins outright and applies to
every period. `payday` and `current` keep their old order — for a payday
today or ahead there is no history to consult, so `payday` collapses into
`current`, which is the only defensible answer for a date that has not
happened.
The cosmetic one: the dialog's hint said "ignored unless the payday is
already in the past", sitting beside a control that plainly appeared to do
something — and was about to become wrong anyway, since manual now always
applies. It is replaced by a line that names the actual period: either
"Payday 2026-09-05 has not passed, so there is no historical rate to look
up — this will be priced at the current rate", or "Will convert at what BTC
was worth on <date>". The dialog header now shows which payday is being
settled, which was not visible at all before.
The contract dialogs lose the same stale hint; their label becomes
"Pricing" rather than "Price a late payday at", which no longer describes
manual mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
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
Until now every payout converted at whatever the rate was when it settled,
so a period paid late was silently mispriced. Contracts now carry a pricing
mode, and every payout records the rate it used plus where that rate came
from — a figure in the ledger can be explained months later instead of
merely trusted.
Three modes, per contract and overridable per payout:
- `payday` (default) converts at what BTC was worth on the payday itself,
via the historical lookup.
- `current` converts at today's rate — correct when the obligation reads
"we owe EUR 800 whenever it settles".
- `manual` converts at a rate the operator states (100000 EUR/BTC), for a
figure that was agreed rather than looked up.
For a payday that is today or ahead, all three collapse to the same thing
and none of them touches the network: LNbits' own live pricing is the
freshest source available, so `resolve_price` returns the fiat amount
unconverted and lets create_invoice do its job. History is consulted only
where it can actually change the answer.
Where payroll does convert, it must hand create_invoice a sat amount —
create_invoice always prices fiat itself and cannot be told a rate. That
moves the single conversion point into payroll, which is why the rate and
its source are recorded on the payout. In `current` mode the rate is read
back off the invoice LNbits priced (extra["btc_rate"]) rather than
recomputed, so the row records the number actually applied.
An unavailable rate raises PricingError and fails the period. Deliberately
no fallback to today's rate: a rate that moved 30% since the payday would
pay 30% off and hide it, which is the class of error nobody finds until an
audit. The existing retry-then-pause machinery already handles a failed
period, and the ledger row names the date and currency that could not be
priced. Manual mode missing its rate is caught at contract-creation time
instead, rather than surfacing as a failed payout weeks later.
m003 defaults preserve behaviour for anything in flight — every live payday
is today or ahead, where the modes agree. Verified by applying m003 to a
copy of the running instance's database: the existing contract and its paid
payout both survive and read back correctly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj