Skip to main content

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

  1. employees.last_working_day + exit_reason stamped, is_active=false.
  2. Every open EmployeeDeployment gets ends_on = last_working_day.
  3. Every future planned ShiftAssignment flipped to cancelled.
  4. Devices revoked, Sanctum tokens deleted.
  5. Pending and shift_manager_approved leaves / regularizations whose window falls after the last working day → auto-rejected with a system-origin decision_trail entry.
  6. Spatie roles stripped, users.is_active = false.
  7. 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_map is optional. Listed employees get their primary deployment moved to the target unit, starting the day after effective_date. Unlisted employees just have their deployment closed.
  • Every planned shift assignment with assigned_for > effective_date at 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_id is optional. Omitting it locks the entire org for that month.
  • Every attendance row in the month stamped with locked_at = now().
  • attendance_locks gets a bookkeeping row with locked_by = actor.
  • Automatically invoked the 1st of every month at 02:00 by AutoLockMonthJob for 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_at from every attendance row in the same scope the lock covered (unit or org-wide).
  • Deletes the attendance_locks row.
  • 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:

  1. Every month in the FY is locked.
  2. No pending leaves / regularizations inside the FY window.
  3. 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:

  1. Close the current primary deployment: DELETE /api/v1/deployments/{id} — defaults to a close (stamp ends_on = today) rather than hard delete. Pass ?force=true for hard delete (rare).
  2. Open a new primary deployment: POST /api/v1/deployments with is_primary = true. The service auto-demotes any overlapping primary row. The race on the demote/promote step is guarded by SELECT ... FOR UPDATE on the Employee row inside the transaction.
  3. 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).