Lifecycle Runbook
One-page reference for the "something was retired / closed / ended" flows. Every flow here is a cascading state change across multiple tables; running them by hand via direct DB edits is a recipe for orphaned devices, stuck approvals, or unlocked attendance rows pretending to be locked. Use the documented endpoints instead.
Quick decision table
| If the customer says… | Use this flow |
|---|---|
| "Employee X has left" | Offboard employee |
| "We're shutting down clinic / site Y" | Retire unit |
| "Acme Corp is no longer a customer" | Retire organization |
| "Close the pay month for March" | Lock month |
| "HR made a mistake, reopen March" | Unlock month |
| "Close financial year 2025-26" | Close FY |
| "Employee moved from Unit A to Unit B" | Transfer employee |
Offboard an employee
Endpoint: POST /api/v1/employees/{id}/offboard
Permission: employee.update (held by hr, org_admin, system_admin)
Service: EmployeeOffboardingService
{
"last_working_day": "2026-05-31",
"reason": "resigned"
}
Cascade (single DB transaction):
employees.last_working_day+exit_reasonstamped,is_active=false.- Every open
EmployeeDeploymentgetsends_on = last_working_day. - Every future
plannedShiftAssignmentflipped tocancelled. - Devices revoked, Sanctum tokens deleted.
- Pending and shift_manager_approved leaves / regularizations whose
window falls after the last working day → auto-rejected with a
system-origin
decision_trailentry. - Spatie roles stripped,
users.is_active = false. - Employee row soft-deleted.
Audit: employee.offboarded. See
Onboarding & Offboarding for
the full rationale.
Retire a unit
Endpoint: POST /api/v1/units/{id}/retire
Permission: unit.delete (held by org_admin, system_admin)
Service: RetireOrganizationUnitService
{
"effective_date": "2026-04-30",
"reason": "Relocation to new site",
"transfer_map": [
{"employee_id": 101, "target_unit_id": 5},
{"employee_id": 102, "target_unit_id": 6}
]
}
transfer_mapis optional. Listed employees get their primary deployment moved to the target unit, starting the day aftereffective_date. Unlisted employees just have their deployment closed.- Every
plannedshift assignment withassigned_for > effective_dateat the unit →cancelled. - Unit flips
status = 'retired',is_active = false. - Guard: refuses to retire the last active unit of an active org (returns 500 with the RuntimeException message). Use the organization-retire flow instead — it handles the single-unit case inline.
Audit: organization_unit.retired with the full cascade summary in
context.
Retire an organization
Endpoint: POST /api/v1/organizations/{id}/retire
Permission: organization.delete (held by org_admin, system_admin)
Service: RetireOrganizationService
{
"effective_date": "2026-04-30",
"reason": "Customer sunset"
}
Cascades RetireOrganizationUnitService across every active unit.
The last unit bypasses the "last active unit" guard via an inline
retire — the guard only makes sense when retiring units individually.
Audit: organization.retired. The before.status = 'active' vs
after.status = 'retired' diff is the single-line compliance check.
Lock a pay month
Endpoint: POST /api/v1/attendance/lock
Permission: attendance.lock (held by hr, org_admin, system_admin)
Service: AttendanceLocker::lock()
{
"organization_id": 1,
"organization_unit_id": 3,
"month": "2026-04"
}
organization_unit_idis optional. Omitting it locks the entire org for that month.- Every
attendancerow in the month stamped withlocked_at = now(). attendance_locksgets a bookkeeping row withlocked_by = actor.- Automatically invoked the 1st of every month at 02:00 by
AutoLockMonthJobfor the just-completed month.
Audit: attendance.lock.
Unlock a pay month
Endpoint: POST /api/v1/attendance/unlock
Permission: attendance.unlock (held by org_admin, system_admin —
HR deliberately excluded for separation of duties)
Service: AttendanceLocker::unlock()
{
"lock_id": 17,
"reason": "Retroactive leave approval for Alice"
}
- Clears
locked_atfrom every attendance row in the same scope the lock covered (unit or org-wide). - Deletes the
attendance_locksrow. - Regression guard (covered by
AttendanceLockerTest): org-wide unlock is scope-gated to only the org that owns the lock; it does NOT clear attendance rows from other tenants.
Audit: attendance.unlock with context.reason echoed.
Close a financial year
Endpoint: POST /api/v1/financial-years/{id}/close
Permission: attendance.unlock (org_admin + system_admin)
Job: CloseFinancialYearJob
Dispatch fire-and-forget — returns 202 Accepted, poll
GET /financial-years/{id} for status === 'closed'.
Pre-close checklist:
- Every month in the FY is locked.
- No pending leaves / regularizations inside the FY window.
- Leave-type catalog reflects current policy (rates are snapshotted at close-time).
See Financial Year Close for the detailed runbook.
Audit: financial_year.closed.
Transfer an employee
No dedicated endpoint — transfer is expressed through the
transfer_map on unit retirement, or manually:
- Close the current primary deployment:
DELETE /api/v1/deployments/{id}— defaults to a close (stampends_on = today) rather than hard delete. Pass?force=truefor hard delete (rare). - Open a new primary deployment:
POST /api/v1/deploymentswithis_primary = true. The service auto-demotes any overlapping primary row. The race on the demote/promote step is guarded bySELECT ... FOR UPDATEon the Employee row inside the transaction. - Cancel any future shift assignments at the old unit and reassign at the new one — there's no automatic move since the new unit likely has a different shift catalog.
Audit: employee_deployment.created (with
demoted_primary_deployment_ids in context) and
employee_deployment.closed.
Common failure modes
"I retired a unit but employees still show there"
The retire cascade only handles employees whose primary deployment was
at that unit. Secondary postings (non-primary EmployeeDeployments) are
left alone on purpose — they're typically short-term cover and HR
decides case by case. Close them via
DELETE /api/v1/deployments/{id} individually if needed.
"I closed an FY but balances look wrong"
Snapshot values come from LeaveType.annual_quota and
LeaveType.carry_forward_max as of close time. If policy changed
the day after close, the snapshot is still the pre-change values. Open
an overriding Leave in the new FY to compensate rather than trying to
retroactively mutate the frozen snapshot.
"Offboarded employee can still log in" The cascade deletes Sanctum tokens but does NOT invalidate an already-streaming web session. Force logout = clear their Sanctum tokens (the cascade does this) plus manually revoke any active Filament sessions if they were an admin. For the React UI the frontend's Zustand store will still carry the old token locally until they refresh; the next API call returns 401 and the store clears.
"Retired org still shows in the dropdown"
The dropdown queries is_active = true. Retired orgs (and units)
flip is_active = false as part of the cascade. If they still show,
the cascade likely didn't run (check the audit log for the
organization.retired entry — if missing, the service short-circuited
on the already-retired guard and there's nothing to fix).