# RunAnniversaryLeaveGrants

## Permission Scope

`grant`

## Overview

Daily batch job that runs the front-load grant schedule for eligible workforce employments, per the `AccrualPlan` generation effective that day. It emits the front-load grant at the **eligibility date** (`grantSource = HIRE`, the first grant) and grants on each subsequent **hire anniversary** (`grantSource = ANNIVERSARY`), always stamped `grantType = STATUTORY` (the invariant: a HIRE/ANNIVERSARY-source grant is STATUTORY). The batch **skips any non-STATUTORY plan** — a MANUAL-provenance grant is issued only via the explicit `grantLeave` command, never materialized by this batch. Per ADR-025 the batch reads the plan's timing parameters (delay, base, tiers, cap, expiration) as data rather than hard-coding the amount, cap, or expiration. Per ADR-026 B1 the batch evaluates `MIN_WORKED_DAYS` via the injected time-tracking `aggregateWorkedDays` query. A consuming application may inject `grantEligibility.evaluate` to evaluate jurisdiction- or company-specific conditions stored in AccrualPlan custom fields without copying the batch. The `MIN_WORKED_DAYS` gate **fails open to granting** on a transient query failure. The batch **dispatches on the plan's `accrualMethod`**: it implements `FRONT_LOAD_TENURE` and skips any plan whose method is anything else.

> Implementation note: because this batch owns both the eligibility-date HIRE grant and anniversary grants, the command may be renamed `runScheduledLeaveGrants` at implementation; the name is retained here for continuity of the doc set.

## Business Rules

- Runs daily as a batch job; for each candidate whose grant date is today, resolves the effective `AccrualPlan` for its employment type (a type-specific plan wins over a generic one; applicability is decided in code, not by DB return order) and grants days computed from that plan, stamped `grantType = STATUTORY` and skipping any non-STATUTORY plan
- **Accrual-method dispatch**: the batch reads the effective plan's `accrualMethod` and only handles `FRONT_LOAD_TENURE` (front-load at the eligibility date + tenure-tier escalation on each anniversary); a plan whose `accrualMethod` is anything else is skipped (counted as skipped, not a failure), leaving room for future methods to be added with their own handlers without a schema migration
- **Grant source by occurrence**: the first grant, landing on hire date + `eligibilityDelayMonths`, is written `grantSource = HIRE`; each subsequent grant, landing on a hire anniversary, is written `grantSource = ANNIVERSARY`. This gives the register and audit trail a distinct front-load vs escalation provenance (the `LeaveGrant` `grantSource` enum: HIRE / ANNIVERSARY / MANUAL)
- Applies the plan's `eligibilityDelayMonths` offset to the grant-date logic: the eligibility date is hire date + `eligibilityDelayMonths` (0 = day one, 6 = statutory, 12 = CN/CA); before it, no grant is made
- Grant amount = `min(applicable tier total ?? baseGrantDays, annualCapDays)`: the applicable tier is the highest tier-table entry with `yearsOfService <= tenure` and its `grantDays` is the **total** entitlement for that tenure (e.g. 11 at 1y, 20 at 6y+); below the first tier the fallback is `baseGrantDays`. No hard-coded 20-day cap — `annualCapDays` (null = uncapped) bounds it. At the eligibility-date HIRE grant tenure is 0, so the amount is `baseGrantDays`
- **Grant-condition gate (ADR-026 C; `MIN_WORKED_DAYS` per ADR-026 B1)**: after the plan and grantSource are resolved and before the grant is inserted, the plan's `grantCondition` is evaluated over a reference window **clamped to the hire date**: `[max(grantDate − referenceMonths, hireDate), grantDate]` (`referenceMonths` default 12; the candidate now carries `hireDate`, the worker's employment start). The clamp exists because the first (HIRE, tenure-0) grant's `grantDate` is hire + `eligibilityDelayMonths` (e.g. 6 months), so an unclamped 12-month window would extend ~6 months before employment — days with no possible worked days that would lower the count and wrongly deny the statutory grant. With the clamp the first grant evaluates only the employment period `[hireDate, grantDate]`; anniversary grants' windows already lie within employment, so the clamp is a no-op for them:
  - `type = MIN_WORKED_DAYS`: the injected time-tracking `aggregateWorkedDays` query counts the candidate's worked days across its `assignmentIds` over the clamped reference window (raw worked days — no `holidayDates` are passed, so holiday-work days count). Worked days below `minWorkedDays` → **no grant this period** (counted as skipped, not a failure); the next period's evaluation is independent
  - `type = NONE` or `grantCondition` null/absent: unconditional grant (v1 behaviour)
- **Fail-open-to-grant on gate query failure**: when `aggregateWorkedDays` returns a failure during `MIN_WORKED_DAYS` evaluation the gate is **not evaluable** and the grant **proceeds** (it is not skipped). Computing the gate from partial data would risk a false refusal of a statutory entitlement (a failed worked-days lookup reads as 0 worked days); over-granting on a rare transient outage is the accepted safer failure mode
- **Application eligibility policy**: after the generic gate and proposed amount are resolved, an optional `grantEligibility.evaluate` callback receives the candidate, the effective plan row including its typed custom fields, and `proposedGrantDays`. `ELIGIBLE` proceeds and may override the amount, `INELIGIBLE` skips the candidate, and `NOT_EVALUABLE` records a failed candidate for retry. Without a callback, only generic conditions apply
- **An overridden amount stays bound by the plan**: an `ELIGIBLE` override must be a non-negative half-day increment (the granularity `grantLeave` enforces on a manual grant) and no larger than the plan's `annualCapDays`, which the model defines as the upper bound on the annual entitlement. An out-of-range amount is **not clamped** — the candidate is counted as failed, so a policy contradicting its own plan configuration surfaces instead of silently altering a statutory entitlement
- Idempotent: skips a worker/leaveType/date/source combination that already has a grant for that exact date and `grantSource` — at most one grant exists per (`workerId`, `leaveTypeKey`, `grantedDate`, `grantSource`) — safe to re-run on the same day
- Uses the `AccrualPlan` generation effective on the grant date to determine the method, tier table, gate, cap, and `expirationMonths`
- Skips employments whose type is not in the plan's `appliesToEmploymentTypeId`
- `expirationDate` = `grantedDate` + the effective plan's `expirationMonths`. `expirationMonths` is required (non-null), so every grant has an expiration; a null `expirationMonths` (no-expiry, e.g. rolls-over jurisdictions) is deferred with the carryover modes
- **KNOWN GAP (narrowed by ADR-026 B1, full closure is a follow-up)**: the WorkerEmployment cross-module wiring (source of hire date, employment type, and assignments) is still not injected — the caller supplies the candidate list, now including each candidate's `assignmentIds` (for the worked-days lookup) and `hireDate` (to clamp the gate reference window to the employment period). Replacing the candidate input with batch-side workforce-query candidate loading is the follow-up step

## Process Flow

```mermaid
flowchart TD
    A[Daily batch trigger] --> B[Load employments whose eligibility date - hire + eligibilityDelayMonths - or a later hire anniversary is today]
    B --> C{Employment type matches an AccrualPlan's appliesToEmploymentTypeId?}
    C -- No --> D[Skip employment]
    C -- Yes --> M{First grant - eligibility date - or subsequent anniversary?}
    M -- First --> N[grantSource = HIRE]
    M -- Subsequent --> O[grantSource = ANNIVERSARY]
    N --> E
    O --> E{Grant already exists for worker + leaveType + today + grantSource?}
    E -- Yes --> F[Skip - idempotent]
    E -- No --> G[Resolve AccrualPlan generation effective today]
    G --> GC{grantCondition type?}
    GC -- MIN_WORKED_DAYS --> W[aggregateWorkedDays over the hire-clamped referenceMonths window across the candidate's assignments - injected time-tracking query]
    W --> WZ{Worked-days query failed?}
    WZ -- "Yes - not evaluable (fail open to grant)" --> P
    WZ -- No --> WT{Worked days >= minWorkedDays?}
    WT -- No --> WS[Skip - GRANT_CONDITION_NOT_MET, no grant this period]
    WT -- Yes --> J
    GC -- "NONE / absent" --> J["Compute proposed grant from tenureTiers, baseGrantDays, and annualCapDays"]
    J --> AP{Application grantEligibility evaluator configured?}
    AP -- No --> K
    AP -- Yes --> AE{Evaluation status}
    AE -- INELIGIBLE --> AS[Skip candidate]
    AE -- NOT_EVALUABLE --> AF[Count failed for retry]
    AE -- "ELIGIBLE - optional amount override" --> AV{Override is a half-day increment within annualCapDays?}
    AV -- No --> AF
    AV -- "Yes / no override" --> K[expirationDate = today + plan.expirationMonths]
    K --> L[Insert LeaveGrant with the resolved grantSource]
```

## External Dependencies

- [leave-management::AccrualPlan](../model/AccrualPlan.md) - supplies the `eligibilityDelayMonths`, base/tier table, `grantCondition` gate, `annualCapDays`, and `expirationMonths` effective on the grant date
- [leave-management::LeaveGrant](../model/LeaveGrant.md) - the HIRE and ANNIVERSARY grants created by this batch
- time-tracking `aggregateWorkedDays` (cross-module, injected via `DefineModuleParams` per ADR-026 B1) - worked-days count over the gate's reference window across the candidate's assignments; for `MIN_WORKED_DAYS` it is called without `holidayDates` (raw worked days)
- application `grantEligibility.evaluate` (optional, injected via `DefineModuleParams`) - evaluates app-owned condition fields and may reject, defer, or adjust the proposed grant
- workforce `WorkerEmployment` (cross-module) - source of hire date, employment-type eligibility, and assignments; still caller-supplied on the candidate (not yet injected — the follow-up closes this)

## Error Scenarios

- **NO_EFFECTIVE_ACCRUAL_PLAN**: no `AccrualPlan` generation is effective for a `leaveTypeKey`/employment type on the run date; the worker is skipped and logged, not treated as a fatal batch failure
- **DUPLICATE_GRANT_SKIPPED**: not a failure — the batch's idempotency skip when a grant already exists for the worker/leaveType/date/grantSource
- **GRANT_CONDITION_NOT_MET**: not a failure — the `MIN_WORKED_DAYS` gate found fewer worked days than the threshold over the reference window, so no grant is issued this period (skip + log); the next period is evaluated independently and tenure progression is unaffected

## Test Cases

- the anniversary batch grants the correct tiered amount and never grants twice for the same worker, leave type, and date
- the first grant at the eligibility date (tenure 0) is written grantSource=HIRE with baseGrantDays
- a FULL_TIME employment gains 11 days on the first anniversary and 20/year from the sixth anniversary onward
- an employment whose type is not in appliesToEmploymentTypeId receives no anniversary grant
- selects the employment-type-specific plan over a generic one regardless of DB return order (M10)
- re-running the batch on the same day skips workers who already received their grant for that date
- skips a worker when no AccrualPlan generation is effective for the leaveTypeKey on the anniversary date
- continues processing remaining candidates and reports the failure count when one insert fails
- a MIN_WORKED_DAYS grant condition skips the worker when worked days over the reference window fall below the threshold
- a MIN_WORKED_DAYS grant condition grants normally when worked days meet the threshold
- the MIN_WORKED_DAYS reference window of a HIRE candidate is clamped to the hire date so pre-employment days never lower the worked-day count
- a MIN_WORKED_DAYS candidate proceeds to grant when the worked-days query fails (fail-open)
- an application evaluator can reject a grant using AccrualPlan custom fields
- an application evaluator can override the generic proposed grant amount
- an application evaluator reports NOT_EVALUABLE as a failed retryable candidate
- an application evaluator amount above the plan's annualCapDays is a failed candidate, not a clamped grant
- an application evaluator amount that is not a half-day increment is a failed candidate
- an application evaluator half-day amount within an uncapped plan is granted
- a non-STATUTORY (MANUAL) plan is skipped — the batch never issues HIRE/ANNIVERSARY MANUAL grants
- a STATUTORY grant is always stamped grantType STATUTORY regardless of source
- a plan whose accrualMethod is not FRONT_LOAD_TENURE is skipped
