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
89 lines
3.7 KiB
Markdown
89 lines
3.7 KiB
Markdown
# 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).
|