Leave — entitlement, balance & multi-stage approval
The whole leave model reduces to one idea: a
Leaveis a dated request (fromDate → toDate) for aleaveType, whose entitlement comes from the employee's assignedLeaveGroup, whose duration is computed from the working-day calendar, and whose approval is delegated to the generic workflow-approval-engine. Balance is not stored — it is derived on demand asentitled − Σ duration(PENDING_APPROVAL + APPROVED)per leave type. There is no accrual ledger and no carry-forward: entitlement is a flat per-year number on the group.
Source: BE src/modules/hr/leave (+ leave/group) · Admin src/modules/hr/leave (+ leave/group)
1. Purpose & scope
The leave module owns the employee absence request lifecycle and entitlement model. It is responsible for:
- Leave groups (
leave/group): named policies that list which leave types an employee may take and how many days of each (numberOfDays) plus whether each is paid (isPaid). An employee is wired to one group viaEmployee.leaveGroupId(see employee). - Leave requests (
leave): a per-employee, per-date-range application for a leave type, with paid/half-day flags, reason, and file attachments. - Balance derivation (
myLeaveBalance): live computation of entitled / used / remaining per leave type — no stored balance. - Duration computation: counting working days in the requested range, excluding weekends and company holidays (via calendar).
- Routing approval through the shared workflow-approval-engine (the orchestrator resolves manager/HOD/HR approvers from the approval-policy).
It explicitly does not:
- Store or accrue balances. There is no
LeaveBalancecollection, no monthly accrual job, no carry-forward / forfeiture logic. "Balance" is recomputed every read from the group entitlement minus used days. Flag (see §4, §9). - Define leave types. Leave types are master-data records (
masterscollection,key: "leave_type") —annual_leave,sick_leave,casual_leave,maternity_leave,paternity_leave,unpaid_leave, etc. (master/constants.ts). The group only references them byleaveTypeId. See master. - Approve leaves itself. Approval state is owned by the workflow engine; the leave service only flips its own status via engine callbacks (
workflowCompleted/workflowRejected). There is no direct approve/reject mutation on the leave resolver (contrast timesheet, which approves in-module). - Deduct payroll directly. Payroll reads approved leaves to compute unpaid-leave deductions and absence handling — see §8 and payroll.
2. Data model
Two collections: leave_groups (the policy) and leaves (the requests). Both @HrSchema-decorated (hr_ semantics) and soft-deleted via mongoose-delete (deletedAt: true). BaseSchema contributes _id, companyId, branchId, ref, documentCode/Date, createdAt/By, updatedAt/By, deleted, canUpdate/View/Delete/Post, client. *Id setters coerce hex strings to ObjectId.
2.1 leave_groups collection — the entitlement policy
Schema: leave/group/group.schema.ts (LeaveGroup extends BaseSchema, @HrSchema({ collection: "leave_groups" })).
| field | type | required | description |
|---|---|---|---|
name |
string | yes | Group label (e.g. "Standard Staff", "Executives"). |
types |
GroupType[] (embedded) |
— | The leave-type allowances. Each entry's leaveTypeId setter coerces to ObjectId. |
GroupType (embedded sub-document — the entitlement line):
| field | type | required | description |
|---|---|---|---|
leaveTypeId |
ObjectId → masters (leave_type) |
— | Which master leave type this allowance is for. |
numberOfDays |
number | — | The annual entitlement for this type (the entitled figure in balance math). 0/unset = no cap (treated as unlimited — see §4.2). |
isPaid |
boolean (default true) |
— | Whether leaves of this type are paid. Drives payroll: unpaid (false) → salary deduction. Used as the default isPaid on a new request. |
The repository's
$lookupLeaveGroupTypesaggregation joins eachtypes[].leaveTypeIdagainstmastersto attachtypes[].leaveType(the master doc withname) for display.
2.2 leaves collection — the request
Schema: leave/leave.schema.ts (Leave extends BaseSchema, @HrSchema({ collection: "leaves" })).
| field | type | required | description |
|---|---|---|---|
employeeId |
ObjectId → employees |
yes | The requester. |
leaveTypeId |
ObjectId → masters (leave_type) |
yes | The leave type (must exist in the employee's group types). |
leaveGroupId |
ObjectId → leave_groups |
— | Snapshot of the employee's group at request time (copied from employee.leaveGroupId on create). |
status |
enum LeaveStatus (default PENDING_APPROVAL) |
— | Lifecycle state. GraphQL-registered. |
fromDate |
number (unix ms) | yes | Start of leave. Also the repository dateKey for range filtering. |
toDate |
number (unix ms) | yes | End of leave (inclusive). |
duration |
number (default 0) |
— | Derived working-day count (see §4.1). 0.5 if isHalfDay. This is the value summed for "used days" and payroll deduction. |
isHalfDay |
boolean (default false) |
— | Half-day request → duration = 0.5. |
isPaid |
boolean (default true) |
— | Paid vs unpaid. Resolved from request → group-type → default true (see §4.3). Unpaid → payroll deducts. |
reasonForLeave |
string | yes | Free-text reason. |
files |
string[] (default []) |
— | Attachment URLs (supporting docs, e.g. MC for sick leave). See files-assets-upload. |
GraphQL-only / derived fields (on the Leave ObjectType, not stored): employeeName (joined from employees → users.name), leaveTypeName (joined from masters), approvalId (exposed nullable but never populated by the resolvers — flag).
Multi-tenancy:
companyIdis copied from the employee on create (companyId: employee.companyId). The repositorybuildQueryapplies the same ESS self-scoping as timesheet: ifcontextSvc.employeeIdis set, the query is forced to that employee — an ESS user only ever sees their own leaves.
2.3 Enum
// leave.schema.ts (mirrored in admin model.ts and GraphQL)
export enum LeaveStatus {
PENDING = "PENDING", // declared but UNUSED by the create flow (see §9)
PENDING_APPROVAL = "PENDING_APPROVAL", // default on create — awaiting workflow approval
APPROVED = "APPROVED", // set by workflowCompleted() callback
REJECTED = "REJECTED", // set by workflowRejected() callback
CANCELLED = "CANCELLED", // set by cancelLeave() (only from PENDING_APPROVAL)
}
PENDINGexists in the enum (and is GraphQL-registered) but no code path ever sets it — every new leave starts atPENDING_APPROVAL. It appears in admin filters/colour maps only. Flag.
2.4 LeaveBalanceItem (computed, not a collection)
@ObjectType() class LeaveBalanceItem {
leaveTypeName: string; // master name (or "Leave" fallback)
entitled: number; // GroupType.numberOfDays
used: number; // Σ duration of PENDING_APPROVAL + APPROVED leaves of this type
remaining: number; // max(0, entitled − used)
}3. API surface
GraphQL — leave requests (leave.resolver.ts)
Guards: @ApGqlAuthorize() + @UseGuards(GqlFeatureGuard) + @RequireFeature("HR_MODULE"). Resolver extends ApBaseResolver<Leave>.
| Operation | Type | Input | Returns | Audit | Notes |
|---|---|---|---|---|---|
createLeave |
Mutation | CreateLeaveInput |
Leave |
CREATE | Validates group/type/balance/approval chain, computes duration, submits to workflow. |
cancelLeave |
Mutation | id |
Leave |
STATUS_CHANGE | Only from PENDING_APPROVAL; archives the workflow task. |
leaveById |
Query | id |
Leave (nullable) |
— | Joins employee name + leave-type name. |
leavePage |
Query | LeavePageInput |
LeavePageResult |
— | Paginated; joins leaveTypeName. ESS self-scoped. |
myLeaves |
Query | LeavePageInput |
LeavePageResult |
— | Forces `employeeId = user.employeeId |
myLeaveBalance |
Query | — | [LeaveBalanceItem] |
— | Live balance for the current user (see §4.2). |
There is no
approveLeave/rejectLeavemutation here. Approval happens on the workflow side (the approver acts on a workflow task / approvals inbox); the engine then calls back into the leave service to flip status.
GraphQL — leave groups (leave/group/group.resolver.ts)
Same guards. Resolver extends ApBaseResolver<LeaveGroup>.
| Operation | Type | Input | Returns | Audit |
|---|---|---|---|---|
createLeaveGroup |
Mutation | CreateLeaveGroupInput { name, types[] } |
LeaveGroup |
CREATE |
updateLeaveGroup |
Mutation | id, UpdateLeaveGroupInput (PartialType) |
LeaveGroup |
UPDATE |
deleteLeaveGroup |
Mutation | id |
Boolean |
DELETE |
leaveGroupPage |
Query | LeaveGroupPageInput { skip, take, keyword? } |
LeaveGroupPageResult |
— |
findLeaveGroup |
Query | LeaveGroupQueryInput { _id?, name? } |
[LeaveGroup] |
— |
findOneLeaveGroup |
Query | id |
LeaveGroup (nullable) |
— |
Input DTOs
@InputType() class CreateLeaveInput {
employeeId: ID; leaveTypeId: ID; fromDate: number; toDate: number;
reasonForLeave: string; isHalfDay?: boolean; isPaid?: boolean; files?: string[];
} // NB: no `duration` — server computes it; no `status` — forced PENDING_APPROVAL.
@InputType() class LeavePageInput { skip; take; status?: LeaveStatus; employeeId?; fromDate?; toDate?; }
@InputType() class GroupTypeInput { leaveTypeId: ID; numberOfDays: number; isPaid?: boolean = true; }
@InputType() class LeaveGroupCommonInput { name: string; types: GroupTypeInput[]; }No class-validator decorators — validation is service-level (§4) plus GraphQL nullability.
REST
None.
4. Business rules & calculations
4.1 Duration = working days in range (via calendar)
On create, LeaveService.createLeave computes:
const holidays = await calendarSvc.findHolidays(employee.companyId, fromDate, toDate);
const duration = calendarSvc.countWorkingDays(fromDate, toDate, holidays, isHalfDay);CalendarService.countWorkingDays (calendar/calendar.service.ts):
DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5]; // Mon–Fri (0=Sun … 6=Sat)
countWorkingDays(from, to, holidayDates, isHalfDay) {
if (isHalfDay) return 0.5;
// count every calendar day in [from, to] whose weekday ∈ workingDays AND is not a holiday
return workingDayStrings(from, to, holidayDates).size;
}Duration = number of Mon–Fri days between
fromDateandtoDateinclusive, minus company holidays; or0.5ifisHalfDay. Holidays come from the calendar (isHoliday: true, percompanyId). The working week is hardcoded Mon–Fri here —countWorkingDaysaccepts aworkingDaysparam but the leave flow never passes a per-employee/shift schedule, so leave duration ignores attendance shift configuration. Flag (see §9).
4.2 Balance = entitled − used (derived, never stored)
myLeaveBalance(employeeId):
- Load employee → its
leaveGroupId→ theLeaveGroup(withtypes). No group →[]. - Load all the employee's leaves with
status ∈ {PENDING_APPROVAL, APPROVED}. - Sum
durationperleaveTypeId→usedByType. - For each
GroupType:
entitled = type.numberOfDays ?? 0
used = usedByType[typeId] ?? 0
remaining = max(0, entitled − used)
Remaining =
numberOfDays − Σ duration(PENDING_APPROVAL + APPROVED)for that type, floored at 0. Pending requests count against the balance (reserved as soon as submitted).REJECTED/CANCELLEDleaves are excluded (they free up the days). There is no accrual (days don't drip in monthly), no pro-rating by join date, and no carry-forward between years —numberOfDaysis a flat figure applied for the whole life of the group assignment. Flag.
4.3 Create validation & invariants (createLeave)
In order, before any write:
- Employee + group required — load employee; if no
leaveGroupId→"Employee has no leave group assigned". - Approval chain resolvable —
orchestrator.validateAndResolveStages(employeeId, Leave)runs first so a leave is never created if no approver can be resolved (no policy, no manager/HOD/HR). Throws e.g."No approval policy configured for Leave"or"No approvers could be resolved…". - Type allowed in group — the group's
typesmust containleaveTypeId, else"Leave type is not available in employee's leave group". - Balance check — sum existing
PENDING_APPROVAL + APPROVEDdurations of that type; ifentitled > 0 && usedDays + duration > entitled→"Insufficient leave balance. You have N day(s) remaining for this leave type."(Whenentitled === 0/unset the check is skipped → effectively unlimited.) - Paid resolution —
isPaid = input.isPaid ?? groupType.isPaid ?? true(request overrides group default; group default overrides hard-defaulttrue). - Persist + submit inside
withRetryTransaction("create_leave"):leaveRepo.create({ ...input, isPaid, leaveGroupId, companyId, duration, status: PENDING_APPROVAL }).orchestrator.resolveAndSubmit({ refId, kind: Leave, employeeId, submitterId: employeeId, ref })→ workflow engine creates approval stages/tasks.
4.4 Status state machine
createLeave
│ (validate → persist → workflow.submit)
▼
┌────────────────┐
│ PENDING_APPROVAL│ ◀── default; balance reserved; workflow task(s) live
└────────────────┘
│ │ │
cancelLeave │ │ │ workflow engine outcome
(archive task) ▼ │ ▼
┌───────────┐ │ ┌──────────┐ workflowCompleted(leaveId)
│ CANCELLED │ │ │ APPROVED │ ◀── all stages approved
└───────────┘ │ └──────────┘
▼
┌──────────┐ workflowRejected(leaveId)
│ REJECTED │ ◀── any stage rejected
└──────────┘
PENDING_APPROVAL → APPROVED: set byLeaveService.workflowCompleted(leaveId)— the callback the workflow engine invokes (viaWorkflowTaskFactory) when the final stage is approved.PENDING_APPROVAL → REJECTED: set byLeaveService.workflowRejected(leaveId)when any approver rejects.PENDING_APPROVAL → CANCELLED:cancelLeave(id)— only allowed fromPENDING_APPROVAL(else"Cannot cancel a leave with status <status>"); runs in a transaction and callsorchestrator.cancelWorkflow(id)→archiveByRefIdto clear stale approver-inbox tasks.APPROVED,REJECTED,CANCELLEDare terminal. There is no edit/update mutation for a leave request — wrong details must be cancelled and re-applied.
4.5 Workflow integration (how leave plugs into the engine)
LeaveService implements IWorkflowTaskKindService and on onModuleInit() registers itself: taskFactory.register(WorkflowTaskKind.Leave, this). The flow:
createLeave → orchestrator.resolveAndSubmit(kind=Leave)
→ orchestrator.validateAndResolveStages: load approval-policy(kind=LEAVE, companyId)
→ for each policy level (sorted by order), map role → approver User._id:
MANAGER → employee.reportingTo → that employee's userId
HEAD_OF_DEPARTMENT → employee.department.hodId (already a User._id)
HR → employee.hrId → that employee's userId
→ build DynamicStage[] { name, order, approveBy (ANY|ALL), approverIds }
→ workflowEngine.submit({ refId: leave._id, kind: Leave, dynamicStages })
→ engine creates stages + WorkflowApproval/Task rows for approvers
[approver acts in approvals inbox] → workflowEngine.approve/reject
→ on final approve → taskFactory.getTaskKindService(Leave).workflowCompleted(leaveId) → status=APPROVED
→ on reject → …workflowRejected(leaveId) → status=REJECTED
The approval policy (hr_approval_policies, kind=LEAVE) defines the levels (role, order, approveBy); roles resolve to concrete users from the employee's reportingTo/hrId and the department's hodId. See approval-policy, approvals, and the full engine in workflow-approval-engine.
Key gotcha:
reportingToandhrIdareEmployee._idand are re-resolved toUser._idbyresolveEmployeeUserId(becauseWorkflowApproval.approverIdmatches aUser._id);department.hodIdis already aUser._id. If none of these are set on the employee, leave creation fails up front (step 2). See employee §9 — notereportingTois not user-editable via the standard form, which can block leave for employees without an imported reporting line.
4.6 Side effects & transactionality
- Create: writes the
leavesrow + workflow stages/tasks atomically insidewithRetryTransaction("create_leave"); audit CREATE snapshot. - Cancel: status flip +
archiveByRefIdinsidewithRetryTransaction("cancel_leave"); audit STATUS_CHANGE. - Approve/reject: owned by the workflow engine's own transaction; the leave-status flip happens in the callback (
leaveRepo.update). - No GL legs, no stock. Payroll consumption is read-only at run time (§8).
5. Permissions
- Feature gate:
@RequireFeature("HR_MODULE")+GqlFeatureGuardon both resolvers. - Auth:
@ApGqlAuthorize()(JWT).myLeaves/myLeaveBalancescope to the caller's employee via@GqlCurrentUser(). - ESS self-scoping (repository):
buildQueryforcesemployeeId = contextSvc.employeeIdfor employee callers, so an ESS user cannot read others' leaves vialeavePage/leaveById. - RBAC (admin-enforced):
USER_ACCESS.LEAVES = { MODULE: 'leaves', ACTIONS: { view, create, update, delete, cancel } }andUSER_ACCESS.LEAVE_GROUPS = { MODULE: 'leave-groups', ACTIONS: { view, create, update, delete } }(zerp-admin/src/constants/UserAccess.ts). Admin buttons gate oncreate. As with timesheet, the BE resolvers apply only the feature gate — action-level RBAC is enforced UI-side. Flag. - Approver authority is governed by the workflow engine (who appears as an approver on a stage), not by the leave module. See permissions-access.
6. Flows
6.1 Apply for leave (happy path)
Admin /hr/leave (or ESS) → "New Leave" → CreateLeave (Formik modal)
→ createLeave({ employeeId, leaveTypeId, fromDate, toDate, reasonForLeave, isHalfDay, isPaid })
→ LeaveResolver.create → LeaveService.createLeave
1. employee + leaveGroupId present? → else "Employee has no leave group assigned"
2. orchestrator.validateAndResolveStages(Leave) → else policy/approver errors
3. leaveTypeId ∈ group.types? → else "Leave type is not available…"
4. usedDays + duration ≤ entitled? → else "Insufficient leave balance…"
5. isPaid = input.isPaid ?? groupType.isPaid ?? true
6. tx: leaveRepo.create(status=PENDING_APPROVAL, duration, leaveGroupId, companyId)
→ orchestrator.resolveAndSubmit → workflowEngine.submit(dynamicStages)
→ audit CREATE ; ← Leave (PENDING_APPROVAL) → approver(s) notified via workflow task
6.2 Approve / reject (workflow side)
Approver → Approvals inbox (workflow task for refId=leave._id)
→ approve → workflowEngine.approve → (final stage) → LeaveService.workflowCompleted(leaveId) → status=APPROVED
→ reject → workflowEngine.reject → LeaveService.workflowRejected(leaveId) → status=REJECTED
(Multi-level: each policy level is a stage; approveBy=ANY needs one approver, ALL needs every approver in that stage. See workflow-approval-engine.)
6.3 Cancel
Requester/admin → row action "Cancel" (shown only for PENDING_APPROVAL)
→ cancelLeave(id) → assert status==PENDING_APPROVAL
→ tx: status=CANCELLED + orchestrator.cancelWorkflow(id) (archive approver tasks)
← Leave (CANCELLED) — reserved days freed in next balance read
6.4 Balance read
ESS/detail → myLeaveBalance → load group.types → sum used (PENDING_APPROVAL+APPROVED) per type
→ [{ leaveTypeName, entitled, used, remaining=max(0, entitled−used) }]
6.5 Unhappy paths
- No leave group on employee →
"Employee has no leave group assigned". - No approval policy / no resolvable approver → thrown before any DB write (leave not created).
- Leave type not in the group →
"Leave type is not available in employee's leave group". - Over balance →
"Insufficient leave balance. You have N day(s) remaining…". - Cancel a non-
PENDING_APPROVALleave →"Cannot cancel a leave with status <status>".
7. Admin UI
| Area | Route | Module |
|---|---|---|
| Leave requests (table + calendar) | /hr/leave, /hr/leave/[id] |
src/modules/hr/leave |
| Leave groups (policies) | /hr/leave-groups (group page) |
src/modules/hr/leave/group |
Leave requests page (page.tsx)
- Two views toggled in the header: Table and Calendar (
ApCalendar). Calendar maps each leave to an event spanningfromDate → toDate, coloured by status (green=APPROVED, orange=PENDING/PENDING_APPROVAL, red=REJECTED, gray=CANCELLED); changing month refetches with that range (pageSize: 100). - Table columns: Ref (links to detail), Employee (
employeeName/ map), Leave Type (leaveTypeName/ master map), From, To, Duration (days), Status, Paid (Yes/No), Reason, Actions. - Row actions: Cancel (confirm popover) shown only for
PENDING_APPROVAL; View Detail (drawer/modalLeaveDetailPage asModal+ link to/hr/leave/[id]). - Filters: Status select (All + each enum value).
fetchMaster()loads leave-type names;fetchEmployeePage({pageSize:1000})loads employee names.
Create form (components/create.tsx)
Formik modal CreateLeave. Yup: employeeId, leaveType (mixed/object), fromDate, toDate, reasonForLeave all required. Inputs: Employee select (disabled when defaultEmployeeId supplied, e.g. from the employee detail page), ApMasterSelectInput masterKey="leave_type" for the type, From/To date pickers, Reason textarea, and two ApSwitchInput toggles Half Day / Paid Leave (default isPaid: true). Submit maps leaveType?._id → leaveTypeId. No duration field — server computes it.
Detail page (detail.tsx)
Read-only: header with employee name, leave-type name, status Tag, Paid/Half-Day tags, short id; From/To/Duration stat block; Reason and Submitted-On. Usable as a full page (/hr/leave/[id]) or embedded drawer (asModal).
Leave groups page (group/page.tsx)
Table of groups: Name + an inline Leave Types sub-table (type name + numberOfDays), with edit/delete row actions and a keyword search. Create/edit via CreateLeaveGroup modal (group/components/create.tsx, async-select.tsx for picking leave-type masters and entering days/paid per row).
Context methods
useLeaveState()(leave/context.tsx):fetchLeavePage,fetchLeaveById,fetchLeaveBalance,createLeave,cancelLeave(+leaves,loading,totalRecords,modal). After create/cancel it toasts and refetches page 1. GraphQL ops (gql/query.ts):LEAVE_PAGE(no-cache),CREATE_LEAVE,CANCEL_LEAVE,LEAVE_BY_ID,MY_LEAVE_BALANCE; errors routed viatoastSvc.graphQlError.useLeaveGroupState()(leave/group/context.tsx):fetchLeaveGroupPage,createLeaveGroup,updateLeaveGroup,deleteLeaveGroup.
The employee detail page also embeds a Leaves tab (
EMPLOYEE_LEAVES) reusing this context, and can pre-fill the create form withdefaultEmployeeId.
8. Dependencies & integrations
Leave depends on / calls:
EmployeeModule(EmployeeService) — resolves the employee, itsleaveGroupId,companyId,reportingTo/hrId. Hard dependency (forwardRef).LeaveGroupModule(LeaveGroupService) — entitlement lookup.CalendarModule(CalendarService) —findHolidays+countWorkingDaysfor duration. See calendar.HrApprovalOrchestratorModule(HrApprovalOrchestratorService) → approval-policy + the workflow engine. Hard dependency for approval routing.WorkflowTaskModule(WorkflowTaskFactory) — registers theLeavekind so the engine can callworkflowCompleted/workflowRejected.master(leave types viamasters,key: "leave_type").AuthModule,SubscriptionModule(feature gate).
Consumed by (read-only):
- payroll —
payroll/employee/employee.service.ts:addUnpaidLeaves: findsstatus=APPROVED, isPaid=falseleaves overlapping the period; deductsΣ (clamped working-days) × dailyRate(half-days honoured; clamped to period boundaries; holidays/non-working days excluded via the shared working-day set).dailyRate = salary / totalWorkingDays. Setsmodel.leaveIdsandmodel.totalUnpaidLeaveAmount.addAbsenceDeductions: treats allAPPROVEDleaves (paid or unpaid) as "present" so they aren't double-counted as absence; unpaid is deducted separately byaddUnpaidLeaves.
- hr-dashboard —
UpcomingLeavesMetrics,onLeaveToday/onLeaveheadcount metrics,ApprovalsMetrics.leave(pending count). - approvals — surfaces the pending workflow tasks created for each leave.
Events: none emitted directly (status changes flow through workflow callbacks, not events). Cron/jobs: none (no accrual job). External: file attachments via the upload pipeline (files-assets-upload).
9. Gotchas & project-specific rules
- No stored balance, no accrual, no carry-forward. Balance is recomputed each read as
numberOfDays − used. Days do not accrue monthly, are not pro-rated by join date, and do not roll over year-to-year.numberOfDaysis a flat lifetime figure on the group. If statutory accrual/carry-forward is required, it must be built (extension point). entitled === 0(or unset) means unlimited, not zero. The balance guard is skipped whenentitled <= 0, so a group type withnumberOfDays: 0allows unbounded requests of that type.- Pending leaves reserve balance.
usedsumsPENDING_APPROVALandAPPROVED. A stuck pending request keeps its days locked until rejected/cancelled. - Approval is fully delegated to the workflow engine. There is no approve/reject on the leave resolver; status only changes via
workflowCompleted/workflowRejectedcallbacks (orcancelLeave). To debug "why is my leave stuck", look at the workflow tasks/approval-policy, not the leave module. - Leave can be un-creatable for employees with no reporting line.
validateAndResolveStagesruns before persistence and throws if no manager (reportingTo)/HOD (department.hodId)/HR (hrId) resolves for the policy's levels. SincereportingTois not editable via the standard employee form (see employee §9), such employees cannot file leave until that link is imported/migrated. PENDINGstatus is dead. Declared + GraphQL-registered, never set by code. OnlyPENDING_APPROVALis used for "awaiting approval".approvalIdfield is exposed but never populated. It's on theLeaveObjectType (nullable) yet no resolver sets it — the live approval lives in the workflow tables keyed byrefId.- Working week is hardcoded Mon–Fri for duration.
countWorkingDayscould take a per-shiftworkingDays, but the leave flow never supplies one — leave duration ignores attendance shift/timetable configuration and only excludes weekends + company holidays. - Half-day = exactly 0.5 day regardless of range.
isHalfDayshort-circuits to0.5even iffromDate ≠ toDate; it does not compute a per-day half across a multi-day range. leaveGroupIdis snapshotted on the leave, but balance reads the current employee group.myLeaveBalanceloads the employee's presentleaveGroupId, not the one stored on past leaves — changing an employee's group re-bases their computed balance against the new entitlements.- No edit path for a submitted leave. There is no
updateLeave; corrections require cancel + re-apply (and cancel is only possible whilePENDING_APPROVAL). - Dates are unix-ms numbers (
fromDate/toDate); the repository treatsfromDateas the rangedateKeyforleavePagefiltering.