Locking
Why
Downstream systems (payroll, billing) need a stable cut-off. Locking a month makes its attendance immutable except for an audited SuperAdmin override.
Triggers
- Auto-lock — a scheduled job on the 5th of the next month at 23:00 UTC locks every
(organization, unit, previous month)tuple that isn't already locked. - Manual lock — HR can lock a specific unit earlier via
POST /api/v1/attendance/lock. - Unlock — only SuperAdmin, via
POST /api/v1/attendance/unlockwith a requiredreason.
Semantics
When locked:
attendances.locked_atis set for every row in scope.PATCH /attendance/{id}returns423 ATTENDANCE_LOCKED.- New punches for that month go to
punch_logs(never refused) but do not mutateattendancesrows — they appear in apost_lock_punchesview for investigation. - Regularizations against locked months are rejected unless SuperAdmin overrides first.
Unlock Flow
sequenceDiagram
participant SA as SuperAdmin
participant API as Laravel API
participant DB as MySQL
SA->>API: POST /attendance/unlock { scope, reason }
API->>DB: audit_logs(action=lock.unlock)
API->>DB: attendance_locks WHERE scope DELETE
API->>DB: attendances.locked_at = NULL for scope
API-->>SA: 200 { unlocked_count }
The unlock emits a domain event that notifies HR and SystemAdmin channels so no silent edits happen.
Re-Lock
After edits, HR (or SuperAdmin) re-locks. A single month can be locked and unlocked multiple times; each cycle is audited with its reason.
Reports
GET /attendance-locks?month=2026-04 lists the lock history for compliance.