payroll/migrations.py
Padreug 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

128 lines
5.2 KiB
Python

"""Payroll schema migrations.
This is an aiolabs-original extension, not a fork of an upstream LNbits one,
so there is no upstream `migrations.py` to stay byte-identical with and the
`migrations_fork.py` split does not apply here — ordinary migrations are
correct. They are still written so a re-run cannot wedge the schema: the
`dbversions` bookkeeping lives in the core DB while the tables live in
`ext_payroll`, and those two writes are not atomic, so a crash between them
replays the migration on the next boot.
Tables live in `ext_payroll.sqlite3` (SQLite) or the `payroll` Postgres
schema.
"""
async def m001_initial(db):
"""Payroll contracts — the standing "pay X this much, this often" rows."""
await db.execute(f"""
CREATE TABLE payroll.contracts (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
employee_username TEXT NOT NULL DEFAULT '',
employee_wallet TEXT NOT NULL,
source_wallet TEXT NOT NULL,
amount REAL NOT NULL,
currency TEXT NOT NULL DEFAULT 'sat',
frequency TEXT NOT NULL DEFAULT 'monthly',
start_date TEXT NOT NULL,
total_periods INTEGER,
periods_done INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
memo TEXT NOT NULL DEFAULT '',
backfill BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
updated_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# The scheduler's hot path is "every contract still eligible to pay".
# NOTE: the schema qualifier goes on the INDEX name, not the table —
# SQLite attaches the extension DB as schema `payroll`, and
# `CREATE INDEX ON schema.table` is a syntax error there.
await db.execute("CREATE INDEX payroll.idx_contracts_status ON contracts (status);")
# Employee-facing lookups ("show me my payroll lines") filter by account.
await db.execute(
"CREATE INDEX payroll.idx_contracts_employee ON contracts (employee_id);"
)
async def m002_payouts(db):
"""The payout ledger — one row per attempt at one period.
Rows are self-describing (they carry the contract terms in force at the
time) so a payout still reads correctly after its contract is edited or
deleted. There is deliberately no foreign key to `contracts` for the
same reason: deleting a contract must not take its history with it.
"""
await db.execute(f"""
CREATE TABLE payroll.payouts (
id TEXT PRIMARY KEY,
contract_id TEXT NOT NULL,
period_index INTEGER NOT NULL,
payday TEXT NOT NULL,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
amount_msat {db.big_int},
amount REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'sat',
employee_id TEXT NOT NULL DEFAULT '',
employee_wallet TEXT NOT NULL DEFAULT '',
source_wallet TEXT NOT NULL DEFAULT '',
payment_hash TEXT,
detail TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
""")
# Counting a period's prior failures runs on every failed attempt.
await db.execute(
"CREATE INDEX payroll.idx_payouts_period "
"ON payouts (contract_id, period_index);"
)
# The employee-facing payslip view filters on the destination wallet.
await db.execute(
"CREATE INDEX payroll.idx_payouts_employee_wallet "
"ON payouts (employee_wallet);"
)
async def m003_pricing_mode(db):
"""Let a back-dated period be priced at the day it was due.
Until now every payout converted at whatever the rate was when it
settled, which silently misprices a period paid late. The contract now
carries how to convert, and each payout records the rate it actually
used plus where that rate came from — so a figure in the ledger can be
explained months later instead of merely trusted.
Defaults preserve existing behaviour for anything already in flight:
every live payday is today or ahead, and for those all three modes agree
on using LNbits' own live pricing.
"""
await db.execute(
"ALTER TABLE payroll.contracts "
"ADD COLUMN pricing_mode TEXT NOT NULL DEFAULT 'payday';"
)
await db.execute("ALTER TABLE payroll.contracts ADD COLUMN manual_rate REAL;")
await db.execute("ALTER TABLE payroll.payouts ADD COLUMN rate REAL;")
await db.execute(
"ALTER TABLE payroll.payouts ADD COLUMN rate_source TEXT NOT NULL DEFAULT '';"
)
async def m004_paused_reason(db):
"""Tell an automatic pause apart from a deliberate one.
Without this they share a recovery path, and resuming a contract that
payroll paused because it could not price a backlog silently writes that
backlog off — the operator fixes the cause, clicks resume, and the money
owed quietly disappears while the contract looks healthy again.
"""
await db.execute(
"ALTER TABLE payroll.contracts "
"ADD COLUMN paused_reason TEXT NOT NULL DEFAULT '';"
)