payroll/docs/data-model.md
Padreug f253b51aa9 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
2026-08-31 13:38:25 +02:00

89 lines
3.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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).