Skip to main content

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 AutoLockMonthJob scheduled in bootstrap/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:

  1. Every month in the FY window is locked (GET /attendance filtered by month shows locked_at on every row).
  2. No pending leaves whose window falls inside the FY — withdraw or decide them. Use POST /leaves/{id}/withdraw for employee-initiated cancels; HR approvers decide the rest.
  3. No pending regularizations — same treatment.
  4. Leave-type catalog reflects current policy (annual_quota, carry_forward_max per 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:

  1. Flips the FY status to closing so a second concurrent call is a no-op.
  2. Iterates every month in the window × every active unit and calls AttendanceLocker::lock(). Idempotent — already-locked months are skipped silently.
  3. For every employee with an active deployment any time during the FY window, and every LeaveType in the org's catalog:
    • Computes consumed = day-count of every hr_approved / overridden Leave of that type whose span intersects the FY window (clipped to the window).
    • Computes opening = prior FY's remaining, capped by LeaveType.carry_forward_max. NULL cap means unlimited carry; 0 cap means lapse.
    • Writes accrued = current LeaveType.annual_quota.
    • Stamps frozen_at = now() so the row is immutable post-close.
  4. Transitions FY status to closed, stamps closed_at + closed_by.
  5. Emits a financial_year.closed audit 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.