feat: payroll contract schema and CRUD

The one entity payroll needs: a standing instruction to pay an employee a
fixed amount, at a fixed cadence, from a given start date, a given number
of times.

Two design decisions worth reviewing here, both documented in
docs/data-model.md:

- Schedule position is `periods_done` (a counter), not a stored
  `next_run_at`. Every payday is recomputed as occurrence(start_date,
  frequency, n), so a late tick cannot make the schedule drift, and a
  contract anchored on the 31st pays 28 Feb then 31 Mar rather than being
  permanently pinned to the 28th.
- `periods_done` counts periods *consumed* (paid or deliberately skipped),
  not periods successfully paid. A failed payout leaves it untouched so the
  next tick retries that payday instead of dropping it.

There is no employee table — an employee is an LNbits account. Only
`employee_username` is copied, and only as a display label so history stays
readable after a rename; authorisation always goes through `employee_id`.

Ordinary migrations, not the migrations_fork.py split: this is an
aiolabs-original extension, so there is no upstream migrations.py to stay
byte-identical with.

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 13:38:25 +02:00
commit f253b51aa9
4 changed files with 424 additions and 0 deletions

53
migrations.py Normal file
View file

@ -0,0 +1,53 @@
"""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);"
)