# RequestLeave

## Permission Scope

`request`

## Overview

Worker files a leave request for one or more target dates. For balance-backed leave types this reserves days from the `LeaveGrant` ledger at filing time (not at approval); non-deducting types validate eligibility only. A mirroring `approval` module request is created for the PENDING decision.

## Business Rules

- `leaveTypeKey` must resolve to an active `LeaveType`; `reason` is required and non-empty
- The command input is `workerId`, `assignmentId`, `leaveTypeKey`, `startDate`, `endDate`, `reason` (the MENSTRUAL gender gate and SPECIAL sub-type validation were dropped in issue #39 and are not part of the input)
- the requested `startDate` must not be after `endDate` (a single-day request has `startDate == endDate`)
- the LeaveType's `requestUnit` drives the day computation: RANGE (or an absent `requestUnit`, back-compat) reserves the inclusive `startDate`–`endDate` span at 1.0 per day; FULL_DAY reserves exactly 1.0; HALF_DAY reserves exactly 0.5 — for the single-day units (FULL_DAY, HALF_DAY) `startDate` and `endDate` must fall on the same UTC calendar day (compared by calendar day, not exact timestamp, since these are date-only fields), otherwise the filing is rejected (INVALID_LEAVE_UNIT)
- `assignmentId` must reference a workforce Assignment effective on the requested dates and belonging to the same `workerId` — validated through the injected workforce `getAssignment`/`getWorkerEmployment` seam (ASSIGNMENT_NOT_EFFECTIVE)
- At most one non-terminal request (PENDING, APPROVED, or CANCEL_PENDING) may exist per `workerId` for any calendar date: filing is rejected when the requested `startDate`–`endDate` range overlaps an existing non-terminal request's range (`existing.startDate <= new.endDate` AND `existing.endDate >= new.startDate`); REJECTED and CANCELLED do not block a new request
- `reason` is required and non-empty (whitespace-only is rejected)
- For `requiresBalance` leave types: reserves the required days (per the `requestUnit` computation above; a HALF_DAY unit reserves 0.5 from a grant) by allocating FIFO from the soonest-expiring unexpired grant(s), splitting the reservation across grants when one is insufficient alone, and inserting one `LeaveConsumption` row per grant drawn — this is the side effect this command has on the `LeaveGrant`/`LeaveConsumption` ledger (cross-feature per the model docs)
- Only grants in force for the request are consumable: `grantedDate <= startDate` and `expirationDate >= endDate` and `expiredAt IS NULL` — a future-dated grant, or a lapsed grant whose `expireLeaveGrants` batch has not run yet, is never drawn, so the result does not depend on batch timing
- A `drawsFromCompensatoryGrants` leave type draws only from COMPENSATORY grants, never STATUTORY/MANUAL (and vice versa)
- Non-deducting types (`requiresBalance = false`) reserve no balance and create zero `LeaveConsumption` rows
- A request whose required days exceed the available ledger balance is rejected outright at filing with no partial reservation
- Filing is rejected if the sole eligible approver (leave-approver role) is the requester, so no request is ever created unapprovable (ADR-003)
- Creates the `LeaveRequest` as PENDING and mirrors it with a bundled approval-module request (one step, leave-approver role, quorum ANY) through the injected approval seam; the resulting ApprovalRequest id is stored in `targetEntityId`, and the approval request targets the LeaveRequest by id (ADR-003)

## Process Flow

```mermaid
flowchart TD
    A[Worker files leaveTypeKey + assignmentId + startDate/endDate + reason] --> B{Eligibility}
    B -- startDate after endDate --> R0[Reject: INVALID_DATE_RANGE]
    B -- FULL_DAY/HALF_DAY unit with startDate != endDate --> R1[Reject: INVALID_LEAVE_UNIT]
    B -- non-terminal request overlaps the requested range --> R3[Reject: DUPLICATE]
    B -- sole eligible approver is requester --> R4[Reject: no valid approver]
    B -- OK --> C{requiresBalance?}
    C -- Yes --> D{Sufficient balance FIFO?}
    D -- No --> R5[Reject: INSUFFICIENT_BALANCE]
    D -- Yes --> E[Lock grants, allocate FIFO by expiration, record LeaveConsumption]
    C -- No --> F[Validate eligibility only, no ledger reservation]
    E --> G[Create LeaveRequest PENDING]
    F --> G
    G --> H[Mirror bundled approval request: one step, leave-approver role, quorum ANY]
```

## External Dependencies

- [leave-management::LeaveGrant](../model/LeaveGrant.md) - FIFO allocation decrements `remainingDays` as a side effect of filing a balance-backed request
- [leave-management::LeaveConsumption](../model/LeaveConsumption.md) - one row inserted per grant drawn from, as a side effect of filing
- [leave-management::LeaveType](../model/LeaveType.md) - `requiresBalance`/`drawsFromCompensatoryGrants` drive filing validation and grant-pool selection
- workforce Assignment / Worker (cross-module) - `assignmentId` scoping to the same `workerId`
- approval module (cross-module, bundled) - mirroring approval request created for the PENDING decision (ADR-003)

## Error Scenarios

- **LEAVE_TYPE_NOT_FOUND**: no LeaveType exists for the given id/key
- **INVALID_DATE_RANGE**: the requested `startDate` is after `endDate`
- **REASON_REQUIRED**: `reason` is empty or whitespace-only
- **INVALID_LEAVE_UNIT**: this leave type must be requested as a single day (startDate and endDate must fall on the same calendar day)
- **DUPLICATE**: a non-terminal request already exists for the worker whose date range overlaps the requested range
- **INSUFFICIENT_BALANCE**: requested days exceed the available ledger balance for the leave type
- **NOT_ASSIGNEE**: no eligible approver exists other than the requester, or the resolver is not an eligible approver
- **ASSIGNMENT_NOT_EFFECTIVE**: `assignmentId` does not cover the requested dates or does not belong to `workerId`

> Error-scenario status: all of the above are enforced — `ASSIGNMENT_NOT_EFFECTIVE` via the injected
> workforce seam, `NOT_ASSIGNEE` via the approval-eligibility pre-check, and the rest inline.

## Test Cases

- filing a balance-backed request reserves the correct days FIFO and records consumption lines
- filing splits a reservation across grants when the soonest-expiring grant is insufficient alone
- filing a menstrual or special leave performs no ledger reservation
- filing fails when requested days exceed the available balance
- filing fails when startDate is after endDate (INVALID_DATE_RANGE)
- filing fails when the reason is empty or whitespace-only (REASON_REQUIRED)
- filing does not consume a future-dated or lapsed grant (grant must cover the request period)
- filing a FULL_DAY-unit request for a single day reserves exactly 1 day
- filing a HALF_DAY-unit request for a single day reserves exactly 0.5 days
- filing a FULL_DAY-unit request accepts start/end on the same calendar day despite differing time-of-day
- filing a FULL_DAY or HALF_DAY unit request spanning multiple days is rejected (INVALID_LEAVE_UNIT)
- a leave type without requestUnit keeps the RANGE behavior (inclusive span)
- filing fails when a non-terminal request already exists overlapping the requested range
- the duplicate guard matches by range overlap, not startDate equality
- a rejected or cancelled request does not block a later request for the same date
- filing a request for an unknown or inactive leave type is rejected
- filing fails when the Assignment does not cover the requested dates (ASSIGNMENT_NOT_EFFECTIVE)
- filing fails when the Assignment belongs to a different worker (ASSIGNMENT_NOT_EFFECTIVE)
- filing mirrors a bundled approval request and stores its id as the request's targetEntityId
- filing is rejected when the sole eligible approver is the requester (NOT_ASSIGNEE)

> Note: `ASSIGNMENT_NOT_EFFECTIVE` and `NOT_ASSIGNEE` are both enforced now, and the real
> approval-request mirror is wired through the injected approval seam (`targetEntityId` holds the
> ApprovalRequest id). (The MENSTRUAL gender gate and SPECIAL sub-type validation were dropped in
> issue #39.)

