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
89
docs/data-model.md
Normal file
89
docs/data-model.md
Normal 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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue