---
type: SQLite Table
title: payroll
description: Computed + admin-adjusted pay per employee per month — base hours pay, tips, bonus, other, and payment status.
resource: backend/src/db/index.ts
tags: [data-model, sqlite, payroll]
timestamp: 2026-08-16T00:00:00Z
---

# payroll

One row per employee per period, created the first time the admin either
saves a draft adjustment or locks the month.

# Schema

| Column | Type | Description |
|---|---|---|
| `id` | INTEGER PK | autoincrement |
| `employee_id` | INTEGER | references [employees](./employees.md) |
| `period` | TEXT | `YYYY-MM` |
| `worked_minutes` | INTEGER | snapshotted at last save/lock, not live |
| `base_amount` | REAL | worked hours × the rate active on each shift's own date — see [pay_rates](./pay-rates.md) |
| `tips_amount` / `bonus_amount` / `other_amount` | REAL | admin-entered, default 0 |
| `final_amount` | REAL | sum of the four amounts above |
| `status` | TEXT | `draft` \| `ready` \| `paid` |
| `payment_date` | TEXT \| NULL | set when marked `paid` |
| `updated_at` | TEXT | ISO 8601 UTC |

`UNIQUE (employee_id, period)`.

# Status lifecycle

```
draft --admin locks the month closure--> ready --admin marks paid--> paid
ready --admin reopens--> draft
```

- **`draft`**: editable. `saveDraftAdjustments`
  (`backend/src/services/payroll.ts`) recomputes `base_amount` /
  `worked_minutes` fresh from
  [attendance_events](./attendance-events.md) on every save, and requires
  the linked [month_closures](./month-closures.md) row to be at least
  `confirmed` (not still `waiting_employee`).
- **`ready`**: frozen. Set by `lockAndFinalizePayroll`, which locks the
  closure in the same call. `saveDraftAdjustments` refuses to touch a
  non-`draft` row — this is what turns "already locked" into the right
  error message instead of the generic "not confirmed yet" one (a real bug
  caught by `payroll.test.ts`: the two checks used to overlap).
- **`paid`**: terminal. Set by `markPaid`, stamps today's date. Not
  reopenable — a mistake past this point needs a manual correction, not a
  silent rewrite.

This collapses the source system's four-stage accountant pipeline
(`READY_FOR_ACCOUNTANT` / `PROCESSING` / `PROCESSED` / `PAID` in
`gscript/PayrollService.js`) down to two — `ready` and `paid` — since
there's no separate accountant role here for the intermediate stages to
mean anything.

# Related

- [month_closures](./month-closures.md)
- [pay_rates](./pay-rates.md)
- [Closure API](/docs/api/closure-routes.md), [Admin API](/docs/api/admin-routes.md)
