fix: honour a stated rate whatever the calendar says

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
This commit is contained in:
Padreug 2026-08-31 22:14:46 +02:00
commit 1584eb337f
5 changed files with 94 additions and 19 deletions

View file

@ -290,12 +290,26 @@ 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 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.
# An explicit rate wins outright, whatever the calendar says. It is an
# instruction rather than a lookup — "pay at the rate we agreed" is as
# meaningful for a payday next week as for one last month, and a
# contract pegged to a fixed rate should honour it on every period.
if mode == PricingMode.manual:
if not manual_rate:
raise PricingError("manual pricing selected but no rate given")
return Price(
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
)
# A payday that is today or ahead has no history to consult, so `payday`
# mode collapses into `current` — a future rate is not knowable, and the
# live one is the only defensible answer.
#
# 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. 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:
amount_sat = await fiat_amount_as_satoshis(contract.amount, contract.currency)
if amount_sat <= 0:
@ -306,13 +320,6 @@ async def resolve_price(
rate = (contract.amount / amount_sat) * 100_000_000
return Price(amount_sat, "sat", rate, "current")
if mode == PricingMode.manual:
if not manual_rate:
raise PricingError("manual pricing selected but no rate given")
return Price(
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
)
historical = await historical_btc_rate(payday, contract.currency)
if historical is None:
raise PricingError(