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
This commit is contained in:
Padreug 2026-08-31 23:02:45 +02:00
commit e77f431d47
8 changed files with 168 additions and 22 deletions

View file

@ -138,9 +138,25 @@ Retries are bounded. After `MAX_PERIOD_ATTEMPTS` (5) failures on the *same*
period, the contract is **paused** and the operator has to act. Pausing
rather than abandoning the period is the point: a payday that cannot be
funded is a fact somebody needs to see, and silently dropping it is the one
outcome payroll must never produce. Because pausing does not advance the
position, resuming after topping up the source wallet retries that same
payday.
outcome payroll must never produce.
### Two kinds of pause, and why resume must tell them apart
| paused by | `paused_reason` | resume does |
|---|---|---|
| the operator | empty | **writes off** the missed paydays |
| payroll | names the period and cause | **keeps** them; they settle next tick |
These mean opposite things. An operator pause *is* the decision not to pay
those periods. An automatic pause means payroll could not pay them and
nobody decided anything — the money is still owed, and the operator has just
fixed whatever blocked it.
Sharing one path between the two destroys money silently: fix the cause,
click resume, and the backlog vanishes while the contract looks healthy.
`resume` therefore infers from `paused_reason`, and `catch_up` overrides it.
The console asks a different question for each and flags a payroll-paused
contract in the table with its reason.
## The payout ledger