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:
parent
a211a8ab8b
commit
f253b51aa9
4 changed files with 424 additions and 0 deletions
53
migrations.py
Normal file
53
migrations.py
Normal 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);"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue