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
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
Rule sets and rule items
A rule set links a uniform category to a list of allocation items:
Each rule item (
EntitlementRuleItem) allocates one product:
Criteria matching
Each rule set has zero or more criteria rows. Each row specifies anattribute (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
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:- In-memory (
evaluateRuleCriteria()) for single-employee eligibility checks - SQL (
buildCriteriaSql()) for bulk operations such as reports and previews
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 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:
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
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-levelentitlementEnforcementEnabled switch on the Client record controls two behaviours: how the engine treats the INITIAL / REPLACEMENT phase split, and how order overflow is classified.
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
cycleMonthsafter 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