> ## Documentation Index
> Fetch the complete documentation index at: https://system.muzemus.online/llms.txt
> Use this file to discover all available pages before exploring further.

# Entitlement engine

> Core evaluation logic that determines what each employee is entitled to and when

The entitlement engine is the most complex subsystem in MUZE. It determines which uniform products an employee is entitled to, in what quantities, and under what payment terms, based on configurable rule sets and criteria matching.

**File:** `backend/src/entitlements/entitlement-engine.service.ts`

The engine answers one question for every order: **what is this employee entitled to right now, and how much of it remains?**

## Key components

| Component                   | File                            | Purpose                                                     |
| --------------------------- | ------------------------------- | ----------------------------------------------------------- |
| `EntitlementEngineService`  | `entitlement-engine.service.ts` | Balance lookup, order preview, consumption commit           |
| `RuleSetsService`           | `rule-sets.service.ts`          | Rule set CRUD, CSV import, `findActiveForEmployee` matching |
| `evaluateRuleCriteria()`    | `entitlement-criteria.ts`       | Pure function: criteria matching (AND/OR)                   |
| `computeEntitlementPhase()` | `entitlement-phase.util.ts`     | Pure function: INITIAL vs REPLACEMENT phase                 |

The two pure functions have no Prisma or NestJS dependencies. This makes the most intricate business logic independently testable and lets the reporting service reuse it.

## Evaluation flow

```mermaid theme={null}
flowchart TD
    A[Order request: employeeId + category] --> B[Load employee facts: store, department, employment type, job title, start date]
    B --> C[Load active EntitlementRuleSets for client + category]
    C --> D{Any active rule sets with criteria?}
    D -->|No| X[No entitlement: employee cannot order in this category]
    D -->|Yes| E[Score rule sets by criteria specificity]
    E --> F{Any rule set matches?}
    F -->|No| X
    F -->|Yes| G[Use the most specific match]
    G --> H[computeEntitlementPhase: INITIAL or REPLACEMENT]
    H --> I[Find or create the EntitlementPeriod for this cycle]
    I --> J[For each rule item: allowed quantity - consumed quantity]
    J --> K{Remaining balance per product}
    K -->|Remaining| L[Order allowed; excess classified employee-paid if enforcement is off]
    K -->|Zero| M[Product is exhausted for this cycle]
```

## Rule sets and rule items

A **rule set** links a uniform category to a list of allocation items:

| Rule set field                  | Meaning                                          |
| ------------------------------- | ------------------------------------------------ |
| `effectiveFrom` / `effectiveTo` | When the rule set applies                        |
| `replacementCycleMonths`        | Cycle length, default 24 months                  |
| `approvalRequired`              | Whether orders under this rule set need approval |
| `active`                        | Only active rule sets participate in matching    |

Each **rule item** (`EntitlementRuleItem`) allocates one product:

| Field                       | Values                           | Meaning                                  |
| --------------------------- | -------------------------------- | ---------------------------------------- |
| `productId` / `productCode` | Product reference                | What is allocated                        |
| `allocationPhase`           | `INITIAL`, `REPLACEMENT`, `BOTH` | Which phase the quantity applies to      |
| `quantity`                  | Integer                          | How many per cycle                       |
| `paymentEligibility`        | `COMPANY_PAID`, `EMPLOYEE_PAID`  | Whether the allocation is company-funded |

## Criteria matching

Each rule set has zero or more criteria rows. Each row specifies an `attribute` (`department`, `store`, `employmentType`, `jobTitle`), an `operator`, and a list of values.

### Evaluation rules

* **AND across rows.** Every row must pass for the employee to match.
* **OR within a row.** The employee's value matches if it equals any value in the row.
* **Null handling.** If an employee attribute is null, any row referencing it fails. Employees cannot match on attributes they do not have.
* **Case-insensitivity.** All comparisons are case-insensitive.

### Operator semantics

| Operator   | Logic                                                    | Example                                 |
| ---------- | -------------------------------------------------------- | --------------------------------------- |
| `IN`       | Value is in the list                                     | `employmentType IN [PERMANENT, CASUAL]` |
| `NOT_IN`   | Value is not in the list                                 | `jobTitle NOT_IN [Trainee]`             |
| `CONTAINS` | Value contains any value as a case-insensitive substring | `jobTitle CONTAINS [manager]`           |

Rule sets with **no criteria rows** act as a catch-all for the category. Runtime matching deliberately queries only rule sets that have at least one criterion (`active: true, criteria: { some: {} }`), so legacy catch-all rules are excluded from live evaluation.

### Specificity scoring

When several rule sets match, the most specifically targeted one wins: a rule set matching on department plus store outranks one matching on department alone. `RuleSetsService.findActiveForEmployee` performs this scoring.

### Dual implementation

Criteria evaluation exists twice with identical semantics:

1. **In-memory** (`evaluateRuleCriteria()`) for single-employee eligibility checks
2. **SQL** (`buildCriteriaSql()`) for bulk operations such as reports and previews

The SQL translation generates parameterized Postgres expressions:

```sql theme={null}
-- department IN [dept-1, dept-2] AND employmentType IN [PERMANENT]
lower(e."departmentId"::text) = ANY($1::text[])
AND lower(e."employmentType"::text) = ANY($2::text[])
```

SQL safety rules: all values are passed as parameters (nothing is interpolated), `CONTAINS` uses `LIKE` with escaped wildcards, unknown operators return `FALSE` (fail closed), and empty `IN` lists return `FALSE` while empty `NOT_IN` lists return `TRUE`.

## Phases and balance

`computeEntitlementPhase()` classifies the employee as `INITIAL` (before the first replacement boundary) or `REPLACEMENT` (after it). The phase is anchored to the store's rollout date where one exists, otherwise to the employee's start date. See [Replacement system](/business-engines/replacement-system) for the exact calculation.

For the matched rule set, the engine finds or creates the employee's `EntitlementPeriod` for the current cycle and computes each product's balance:

```
remaining = allowed quantity (rule item) − consumed quantity (order items recorded against the period)
```

In free-flow mode (enforcement off) the allowed quantity is the merged maximum across all rule items that allocate the same product, because the phase split is ignored. See [Enforcement mode](#enforcement-mode-free-flow-vs-strict).

Consumption is written onto each `OrderItem` (`entitlementPeriodId` + `entitlementQuantityConsumed`) inside the order-creation transaction, so concurrent orders cannot over-allocate. Cancelling an order releases the consumed quantity.

## Payment split and enforcement

During order preview the engine classifies every line item:

* **Company-paid** when it fits within the remaining allocation for an allocation-eligible product
* **Employee-paid** when it exceeds the allocation

When a client has `entitlementEnforcementEnabled` turned on, company-paid quantities beyond the remaining balance are rejected outright instead of becoming employee-paid. The default is off.

## Enforcement mode: free flow vs strict

The client-level `entitlementEnforcementEnabled` switch on the `Client` record controls two behaviours: how the engine treats the INITIAL / REPLACEMENT phase split, and how order overflow is classified.

| Switch                       | Phase handling                                                                                                                                                                                                                                               | Order-time overflow                                                       |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| **OFF** (free flow, default) | The phase split is ignored. Every item in the matched rule set is eligible, so an entitled employee sees the full product set. Items that allocate the same product across more than one phase merge to the larger allowed quantity and are labelled `BOTH`. | Excess company-paid quantity becomes employee-paid, with a warning        |
| **ON** (strict)              | Only rule items that apply to the employee's current phase (`INITIAL` or `REPLACEMENT`) count towards the balance.                                                                                                                                           | Excess company-paid quantity is rejected outright (`BadRequestException`) |

In free flow the engine still computes the current phase, cycle boundaries, and replacement dates so that phase-aligned reporting stays accurate; only the *product eligibility* filter is phase-agnostic. Consumption is recorded against the order's entitlement period in both modes and the current-cycle consumed quantity is deducted from the merged balance. Criteria matching still applies unchanged, so an employee who does not match any rule set remains `NOT_ELIGIBLE`.

Worked example — a rule item for the Boxer product `BOX023` is allocated as `INITIAL` only:

* Under **strict** mode a replacement-phase employee does not see the item at all.
* Under **free flow** the same employee sees it and can order it at the rule-set quantity, because the phase split no longer hides it.

## Boxer policy semantics

The engine implements Boxer's entitlement policy:

* One replacement cycle per rule set (for example 24 months)
* Rollouts are scheduled store by store: a store is anchored to a rollout date
* Replacement boundaries fall every `cycleMonths` after the store anchor
* Employees are not phased on their own anniversary; the store anchor drives the cycle
* Stores without an anchor fall back to per-employee start dates

## CSV import

Rule sets and their criteria can be bulk-imported. The CSV format and validation rules are documented in [Import system](/notifications-reports/import-system).
