Financial Year Close
Runbook for closing a financial year. The system models an FY as a
per-org row in financial_years with a state-machine status of open → closing → closed, plus a companion leave_balances snapshot that
freezes every employee's leave accounting at close time.
Timing
Indian deployments use April–March by default. The
OrganizationBootstrapService opens the "current" FY automatically
when a new organization is created, sized off
organization.settings['fy_start_month'] (1–12, defaults to 4).
Plan the close for the first week of April:
- Last monthly lock of the FY runs automatically on April 1st 02:00
(see
AutoLockMonthJobscheduled inbootstrap/app.php). - HR finishes any last-minute regularizations by April 3rd.
- Trigger the FY close on April 4th or 5th.
Pre-close checklist
Before dispatching the close job, confirm:
- Every month in the FY window is locked (
GET /attendancefiltered by month showslocked_aton every row). - No
pendingleaves whose window falls inside the FY — withdraw or decide them. UsePOST /leaves/{id}/withdrawfor employee-initiated cancels; HR approvers decide the rest. - No
pendingregularizations — same treatment. - Leave-type catalog reflects current policy (
annual_quota,carry_forward_maxper type). Update before close — the snapshot uses the values at close-time.
Running the close
POST /api/v1/financial-years/{id}/close
// no body needed
Gated by attendance.unlock permission — only org_admin and
system_admin can dispatch. hr explicitly cannot (separation of
duties: HR owns the monthly close, org admin owns the annual seal).
The endpoint dispatches CloseFinancialYearJob and returns 202
Accepted with status: "closing". Poll
GET /financial-years/{id} until status === "closed" and
closed_at is populated.
What the job does
In a single transaction:
- Flips the FY status to
closingso a second concurrent call is a no-op. - Iterates every month in the window × every active unit and calls
AttendanceLocker::lock(). Idempotent — already-locked months are skipped silently. - For every employee with an active deployment any time during the
FY window, and every
LeaveTypein the org's catalog:- Computes
consumed= day-count of everyhr_approved/overriddenLeave of that type whose span intersects the FY window (clipped to the window). - Computes
opening= prior FY'sremaining, capped byLeaveType.carry_forward_max. NULL cap means unlimited carry; 0 cap means lapse. - Writes
accrued= currentLeaveType.annual_quota. - Stamps
frozen_at = now()so the row is immutable post-close.
- Computes
- Transitions FY status to
closed, stampsclosed_at+closed_by. - Emits a
financial_year.closedaudit record with the full summary.
Reading the snapshot
GET /api/v1/financial-years/{id}/balances
Returns, per employee + leave type:
{
"employee_id": 101,
"employee_name": "Alice",
"leave_type_code": "earned",
"leave_type_name": "Earned leave",
"opening": 5.0,
"accrued": 15.0,
"consumed": 12.0,
"remaining": 8.0,
"frozen_at": "2026-04-05T14:00:00+05:30"
}
These rows are frozen at close; subsequent backdated leave decisions do NOT retroactively mutate them. If a post-close correction is needed, open an overriding Leave in the new FY and compensate in next-year's balance.
Opening the next FY
OrganizationBootstrapService::openFinancialYear() opens the new FY
lazily the first time someone hits GET /financial-years for the
org on or after the new FY's starts_on. To open explicitly:
POST /api/v1/financial-years
{
"organization_id": 1,
"label": "2027-28",
"starts_on": "2027-04-01",
"ends_on": "2028-03-31"
}
The close job for the previous FY uses this next FY as the opening carry-forward target automatically.
Reversing a close
There is no UI path for reopening a closed FY — it's deliberately a
one-way door to protect historical integrity. If reopening is
genuinely needed, the system administrator must do it manually by
updating financial_years.status = 'open' in the DB and dropping the
leave_balances.frozen_at for the affected FY. The attendance locks
from the cascade stay in place and must be unlocked individually via
POST /attendance/unlock with lock_id.