Found by tracing what happens when a back-dated backfill contract cannot
fetch a historical rate. The failure handling itself was fine — period 0
fails, the backlog halts so nothing settles out of order, five ledger rows
record the reason, no money moves, and the contract auto-pauses once the
retry budget is spent. The recovery was not.
The operator fixes the cause (switches to a stated rate, or to current),
clicks Resume, and periods_done jumps 0 -> 6: every unpaid payday silently
written off, contract back to looking healthy, employee never paid. The
confirm dialog even asserted the missed paydays "are written off" — true of
one kind of pause and a lie about the other.
Two features colliding. "Do not backfill a deliberate pause" is right when
the operator paused: the pause *was* the decision not to pay. It is wrong
when payroll paused, because nobody decided anything — the money is still
owed and the operator has just removed whatever blocked it.
Contracts now carry `paused_reason`, set only when payroll pauses them and
cleared by a deliberate pause. Resume infers from it, and an explicit
`catch_up` still overrides either way. The console asks a different question
for each, quoting the reason, and flags a payroll-paused contract in the
table so the distinction is visible before anyone clicks.
Verified end to end: five failing ticks leave periods_done at 0 and pause
with "period 0 (2026-08-01) failed 5 times: no historical EUR rate
available for 2026-08-01"; resuming after switching to a manual rate keeps
the position at 0, and the next tick settles all seven owed periods.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
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
Surfaces the pricing modes in the console: a selector plus a rate field on
both the create and edit dialogs, and a "Rate used" column in the ledger
showing the figure alongside where it came from, so a payout can be
explained without opening the database.
Pay-now becomes a dialog rather than a confirm. That is the moment an
operator is most likely to want a rate other than the contract's own —
entering a payment that happened weeks ago at a figure they already know —
and the choice has to be made before it settles, not after. It defaults to
the contract's mode, disables the rate field unless "Rate I enter" is
picked, and refuses to submit a manual mode with no rate. The success toast
now names the rate applied, and a refused payout still shows its reason.
The mode selector is disabled for sat-denominated contracts, where there is
no conversion to have an opinion about, and its hint says the setting only
applies to a payday already in the past — otherwise it reads as though it
governs every payout, which it does not.
Quasar UMD rules honoured: no self-closing tags, `${ }` delimiters,
`:style` bindings rather than a <style> block.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
The page the whole extension exists to be driven from: pick a user, pick
which of their wallets to pay into, set amount, currency, frequency, start
date and how many payments — plus the ledger, filters and a CSV button.
Notes for review:
- The employee-wallet picker only offers wallets belonging to the selected
employee. The API rejects anything else, so this is about not presenting
the mistake rather than about enforcement.
- The dialog draws a live calendar from whatever is currently typed. A
wrong start date or frequency is cheapest to catch before saving, which
is what the preview endpoint was for.
- "Next payday" comes from the schedule endpoint per row rather than being
computed in JS. A payday this page derived for itself could disagree with
the one the scheduler will actually use, and month-end is exactly where
that would happen.
- The destructive actions say what they do: resume warns that missed
paydays are written off, delete says the schedule position goes with the
row and points at cancel instead, pay-now says the period is consumed
even if its payday has not arrived.
- A refused pay-now surfaces the ledger row's reason with a longer toast —
triggering a payout by hand is precisely when you want to know why it
did not land.
Quasar UMD rules honoured: no self-closing tags anywhere in the template,
`${ }` delimiters so Jinja never sees a moustache, and `:style` bindings
instead of a <style> block, since LNbits themes override typography
utilities with !important.
The page route is gated on super_user as well, via check_user_exists plus
an explicit flag check — the template needs the full User for its wallet
picker, which check_super_user does not return. It is a UX nicety; the API
behind it is gated independently and does not trust this route.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj