--- type: SQLite Table title: month_closures description: One row per employee per calendar month tracking the confirm -> lock workflow that gates payroll. resource: backend/src/db/index.ts tags: [data-model, sqlite, payroll] timestamp: 2026-08-16T00:00:00Z --- # month_closures Tracks where a given employee's given month is in the confirm/lock workflow. Rows are created lazily — the first time the employee opens that period — not proactively for every employee/month combination. # Schema | Column | Type | Description | |---|---|---| | `id` | INTEGER PK | autoincrement | | `employee_id` | INTEGER | references [employees](./employees.md) | | `period` | TEXT | `YYYY-MM` | | `status` | TEXT | `waiting_employee` \| `confirmed` \| `locked` | | `employee_confirmed_at` | TEXT \| NULL | set on confirm | | `locked_at` | TEXT \| NULL | set on lock, cleared on reopen | `UNIQUE (employee_id, period)`. # State machine ``` waiting_employee --employee confirms--> confirmed confirmed --admin locks--> locked locked --admin reopens--> confirmed (undoes a premature lock) ``` - **Confirm** (`confirmClosure`, `backend/src/services/closure.ts`): employee-only action, blocked while any session in that period is still `open` (see [attendance_events](./attendance-events.md)). Idempotent — confirming an already-confirmed month is a no-op, not an error. - **Lock** (`lockClosure`, called from `lockAndFinalizePayroll` in `backend/src/services/payroll.ts`): admin-only, requires `confirmed`, re-checks for an open session (defensive — time may have passed since confirm), freezes the [payroll](./payroll.md) row. - **Reopen** (`reopenPayroll`): undoes a lock made before the numbers were actually right (e.g. the hourly rate wasn't set yet) — back to `confirmed`, with the payroll row reset to `draft`. Only possible while payroll status is `ready`, not once `paid` — that's the point past which a mistake has to be corrected some other way, not silently rewritten. Unlike the source Google Apps Script this was ported from (`gscript/ClosureService.js`), there's no separate `MANAGER_APPROVED` state — that system modeled independent ADMIN/MANAGER/ACCOUNTANT roles; this app has one owner-admin, so "manager approves" and "admin locks" collapse into a single action here. # Related - [payroll](./payroll.md) - [attendance_events](./attendance-events.md) — `hasOpenSessionInPeriod` gate - [Closure API](/docs/api/closure-routes.md)