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

3.7 KiB
Raw Permalink Blame History

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