Commit graph

8 commits

Author SHA1 Message Date
e77f431d47 fix: resuming an auto-paused contract no longer discards the backlog
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
2026-08-31 23:02:45 +02:00
1584eb337f 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
2026-08-31 22:14:46 +02:00
3b14db3dff 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
2026-08-31 21:55:49 +02:00
18a17cd693 feat: price a back-dated period at the day it was due
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
2026-08-31 21:42:00 +02:00
ee10b2e04d feat: schedule preview and off-cycle payouts
Two things an operator needs that the scheduler alone does not give them.

Preview draws a contract's calendar from loose terms — start date,
frequency, period count — with no employee or wallet required, because the
schedule is what an operator wants to sanity-check first and a mistyped
start date is cheapest to fix before anything is saved. The same shape is
available for a live contract, from its current position. Both return
`ends_on`: "12 monthly payments from 15 Jan" is far easier to verify
against "ends 15 Dec" than against a list of twelve dates.

pay-now settles the next period immediately and consumes it. Deliberately
one endpoint rather than two: "run it now, don't wait for the tick" and
"pay it early" are the same operation and differ only in whether today
happens to be the payday. It bypasses the back-dated skip — that guard
exists to stop a new contract firing surprise back-pay, and an operator
explicitly asking to pay is not a surprise — and it takes the same
per-contract lock as the scheduler, which is what that lock was added for.
The ledger row is returned even on failure, since a manual payout that did
not land is exactly when you want the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:51:20 +02:00
8b054d27ea feat: payout ledger with bounded retry
Closes the gap the scheduler commit left open: a failed payday was retried
indefinitely with nothing but a log line to show for it.

Every attempt — paid, skipped and failed — is now written to
payroll.payouts and exposed at GET /api/v1/payouts. Failures are in the
ledger, not only in the log, because "why did nobody get paid on the 1st"
is the question the ledger exists to answer.

Ledger rows are self-describing: each copies the terms in force at the time
(amount, currency, both wallets) instead of pointing at the contract, and
there is no foreign key to contracts. A payout has to still read correctly
after its contract is edited, and deleting a contract must not take its
history with it. This is the one place duplicating a contract field is
right — the contract holds what is true now, a payout holds what was true
then.

Retries are bounded at 5 attempts per period, after which the contract is
paused rather than the period abandoned. A payday that cannot be funded is
a fact somebody has to act on; dropping it silently is the one outcome
payroll must never produce. Pausing does not advance the position, so
resuming after topping up retries the same payday. The attempt count is
derived from the ledger rather than a column on the contract, so it
survives a restart and stays auditable.

Also restores the explanatory comments on the broad `except Exception`
handlers, which ruff's RUF100 stripped along with their now-unused noqa
directives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:49:46 +02:00
95bfa86f79 feat: contract lifecycle — pause, resume, cancel
Beyond create and delete, a payroll line needs to be stoppable without
being erased. Three transitions, expressed as pure functions on the model
so the rules are testable without a DB, and mapped to 409 at the API
boundary — a refused transition is a well-formed request that the
contract's current state declines.

The decision with money attached is what resume does about the paydays that
fell while the contract was paused. It skips them: a pause is a decision
not to pay, and resuming into an unannounced multi-period transfer is the
opposite of what "resume" implies. `catch_up=true` opts into paying them,
for a pause that was an operational hold rather than a call about the
money.

Cancel is now the way to stop a running payroll; DELETE stays as the
"created it by mistake" escape hatch, and the docstrings say which is
which, because deleting discards the schedule position and the record that
the contract ever existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:47:07 +02:00
99f2131474 feat: recurring payout scheduler
Turns a contract into money moving. One permanent task ticks every five
minutes and settles whatever each active contract owes; the actual transfer
is a plain internal LNbits invoice on the employee's wallet, paid from the
source wallet.

services.py is split into a pure half and an effectful half on purpose.
Paydays are the part of payroll that is easy to get subtly wrong and
expensive to get wrong in production, so the schedule math has no DB, no
wallets and no clock of its own, and is covered by tests.

Decisions worth reviewing:

- The n-th payday is a function of start_date and n alone. Advancing a
  stored date would drift on every late tick and would pin a month-end
  contract to the 28th forever; anchoring means 31 Jan pays 28 Feb and then
  31 Mar. Tested both ways round.
- A failed period does not advance the contract's position, and a backlog
  halts at the first failure so paydays cannot settle out of order.
- The sat amount is derived exactly once, by create_invoice, and the value
  it returns is what gets recorded — never recomputed from amount x rate.
- Back-dated start dates skip rather than back-pay by default; a mistyped
  start date is far more likely than a genuine back-pay request. Explicit
  `backfill` opts in, and a single tick is capped at 12 periods either way.
- Per-contract asyncio lock, with the row re-read under it. Not needed by
  the scheduler alone, but off-cycle payout paths land mid-tick and
  double-paying is the worst thing this extension could do.

Known gap, addressed by the payout-ledger commit that follows: a failure is
retried indefinitely, once per tick, with nothing but a log line to show
for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:45:40 +02:00