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

89
docs/data-model.md Normal file
View file

@ -0,0 +1,89 @@
# Payroll data model
## The one entity that matters
Everything hangs off a **contract**: a standing instruction to pay one
employee a fixed amount, at a fixed cadence, starting on a fixed date, a
fixed number of times.
```
contract
├─ who employee_id → employee_wallet (where the money lands)
├─ from source_wallet (where the money leaves)
├─ how much amount + currency (the agreed instruction)
└─ when start_date + frequency + total_periods
```
There is no separate "employee" table. An employee *is* an LNbits account;
duplicating account rows here would immediately drift from core. The only
thing copied out of core is `employee_username`, and that is display-only —
a label so a payroll history still reads sensibly after an account is
renamed or deleted. Authorisation and lookup always go through
`employee_id`.
## Schedule position
A contract stores `periods_done`, not a `next_run_at` timestamp.
Every payday is computed as `occurrence(start_date, frequency, n)` for
period index `n`, so the *n*-th payday depends only on the start date. Two
consequences worth the trade:
- **No drift.** Incrementally advancing a stored date accumulates error —
one late tick and every subsequent payday shifts. Recomputing from the
anchor cannot drift.
- **Month-end behaves.** A contract anchored on 31 Jan pays 28 Feb, then
**31** Mar — because March is computed from January, not from February.
Advancing month-by-month would have pinned it to the 28th forever.
`periods_done` counts periods **consumed**, which is paid *or* deliberately
skipped — not periods successfully paid. A failed payout leaves it alone,
which is exactly what makes the next tick retry the same payday instead of
quietly dropping it. "How many actually paid" is a question for the payout
ledger, not for this counter.
## Money: instruction vs. fact
| | lives on | means |
|---|---|---|
| `amount` + `currency` | contract | the **agreed instruction** — "€800 a month" |
| the sat figure paid | payout record | the **settled fact** for one period |
A fiat contract is converted to sats once per period, at payout time, by
LNbits' own invoice pricing. Whatever comes back is recorded as-is and is
canonical for that period. Nothing downstream recomputes it from
`amount × rate`: FX moves between quote and settlement, and rounding
accumulates over a year of paydays.
## Dates are civil dates
`start_date` is a `YYYY-MM-DD` string, not a DB date or timestamp. A payday
is a calendar fact ("the 31st"), not an instant. Storing it as a timestamp
invites a timezone normalisation to move a payday across a month boundary,
which for a monthly contract silently changes which month gets paid.
## Lifecycle
```
┌──────────┐
│ active │──────── periods exhausted ───────▶ completed
└────┬─────┘
│ ▲
pause │ │ resume
▼ │
┌──────────┐
│ paused │
└──────────┘
│
└──────────── cancel ─────────────────────▶ cancelled
```
`completed` and `cancelled` are terminal. `paused` keeps its schedule
position: resuming does not backfill the paydays that passed while paused,
because a pause is a decision not to pay them.
## Tables
`payroll.contracts` in `ext_payroll` (SQLite) or the `payroll` Postgres
schema. Indexed on `status` (the scheduler's hot path: "every contract
still eligible to pay") and on `employee_id` (the employee-facing view).