# Rules Module — Architecture Map

> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Rules module is the **central business rule engine** for the entire ERP. It manages:
- Business rule storage, retrieval, and caching (the `business_rules` table)
- Rule versioning with full audit trail (who changed what, when, and why)
- Rule overrides per entity (member-specific, branch-specific exceptions)
- Date-bounded rule validity (effective_from/effective_to for time-traveling calculations)
- Branch-specific rule variants with global fallback
- Rule administration UI (view, edit, history, simulate)
- Provides `RuleEngineInterface` contract for dependency injection

It does **NOT** directly manage:
- Pricing calculations (Pricing module's PricingEngine uses Rules as data source)
- Service catalog entries (ServiceCatalog module)
- UI rendering of rule forms in the dashboard (Pricing module's RuleFieldMapper)
- Business logic that USES the rules (spread across Members, Children, Spouses, etc.)

---

## 2. Directory & File Structure

```
app/Modules/Rules/
├── bootstrap.php                      # Menu (shared with Pricing/ServiceCatalog) + permission registration
├── Routes.php                         # Web routes (5 routes)
├── provides.php                       # ModuleRegistry: registers RuleEngineInterface service
├── RuleEngineAdapter.php              # Adapter implementing RuleEngineInterface (delegates to RuleEngine)
├── Controllers/
│   └── RuleController.php            # Index, edit, update, history, simulate
├── Models/
│   ├── BusinessRule.php              # business_rules model (allByCategory, getCategories)
│   ├── RuleOverride.php              # rule_overrides model (entity-specific exceptions)
│   └── RuleVersion.php              # rule_versions model (change audit trail)
├── Services/
│   └── RuleEngine.php               # CORE: get, getValue, require, update, override, getEffective, cache
└── Views/
    ├── index.php                     # Rule list with category filter
    ├── edit.php                      # Edit rule JSON value + reason
    ├── history.php                   # Version history for a rule
    └── simulate.php                  # Rule simulation view
```

---

## 3. Database Schema (Production — Source of Truth)

### 3.1 `business_rules` Table (143 active rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| rule_code | varchar(100) | NO | MUL | Unique identifier (e.g., CHILD_FEE_AGE_18) |
| category | varchar(50) | NO | MUL | Grouping (financial, spouse_fee, children_fee, etc.) |
| name_ar | varchar(300) | NO | | Arabic description |
| name_en | varchar(300) | YES | | English description |
| description_ar | text | YES | | Detailed Arabic explanation |
| description_en | text | YES | | Detailed English explanation |
| parameters_json | json | YES | | Schema definition for the rule's parameters |
| current_value_json | json | NO | | Current value (JSON object) |
| data_type | varchar(50) | NO | | Default: 'string' |
| branch_id | bigint unsigned | YES | MUL | NULL = global, otherwise branch-specific |
| effective_from | date | NO | MUL | Start of validity period |
| effective_to | date | YES | | End of validity (NULL = indefinite) |
| is_active | tinyint(1) | NO | MUL | Default: 1 |
| version | int unsigned | NO | | Current version number, Default: 1 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |

**Category distribution (active rules):**
- age: 17
- children_fee: 12
- death: 4
- discount: 6
- divorce: 9
- financial: 11
- foreign: 6
- inventory: 9
- membership: 3
- penalties: 2
- penalty: 14
- separation_fee: 8
- sports_activity: 12
- spouse_fee: 11
- subscription: 2
- temporary: 5
- transfer: 1
- workflow: 11

### 3.2 `rule_versions` Table (4 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| rule_id | bigint unsigned | NO | MUL | FK to business_rules.id |
| version_number | int unsigned | NO | | Sequential version |
| old_value_json | json | YES | | Previous value (NULL for first version) |
| new_value_json | json | NO | | New value |
| changed_by | bigint unsigned | YES | | FK to employees |
| changed_at | timestamp | NO | | |
| change_reason | text | YES | | Required by UI but nullable in DB |

### 3.3 `rule_overrides` Table (0 rows)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| rule_id | bigint unsigned | NO | MUL | FK to business_rules.id |
| entity_type | varchar(100) | NO | MUL | e.g., 'member', 'branch' |
| entity_id | bigint unsigned | NO | | ID of the entity |
| override_value_json | json | NO | | Override value |
| reason | text | NO | | Justification |
| approved_by | bigint unsigned | YES | | FK to employees |
| effective_from | date | NO | | |
| effective_to | date | YES | | NULL = indefinite |
| is_active | tinyint(1) | NO | | Default: 1 |
| created_at | timestamp | NO | | |

---

## 4. Routes (5 endpoints)

| Method | Path | Handler | Permission |
|--------|------|---------|------------|
| GET | /rules | RuleController@index | rules.view |
| GET | /rules/{id}/edit | RuleController@edit | rules.edit |
| POST | /rules/{id} | RuleController@update | rules.edit |
| GET | /rules/{id}/history | RuleController@history | rules.view |
| GET | /rules/{id}/simulate | RuleController@simulate | rules.view |

---

## 5. RuleEngine — Core Service

The `RuleEngine` is a **static service class** that provides the single point of access for all business rules system-wide.

### 5.1 `get(ruleCode, branchId?, date?)`
- **Primary lookup method** — used by every module needing a rule value
- Tries branch-specific first, then global fallback
- Date-bounded: only returns rules where `effective_from <= date <= effective_to`
- **In-memory cache** per request (key: `{ruleCode}_{branchId}_{date}`)
- Returns decoded JSON value (array) or null

### 5.2 `getValue(ruleCode, key, branchId?)`
- Convenience wrapper: gets a specific key from the rule's JSON
- Fallback chain: `$data[$key]` → `$data['percentage']` → `$data['amount']` → `$data['value']`

### 5.3 `require(ruleCode, branchId?)` / `requireValue(ruleCode, key, branchId?)`
- Same as get/getValue but throws RuntimeException if rule not found
- Logs error before throwing
- Arabic error message for UI display

### 5.4 `getEffective(ruleCode, entityType, entityId, branchId?)`
- Checks for entity-specific override FIRST, then falls back to standard get()
- Used when a member has a special rule exception

### 5.5 `update(ruleCode, newValueJson, reason, branchId?, effectiveFrom?)`
- **Creates a new version** in `rule_versions` table
- If effectiveFrom matches current rule's effective_from: updates in place
- If effectiveFrom is different: closes old rule (sets effective_to) and inserts new row
- Clears entire cache after update

### 5.6 `addHistoricalVersion(ruleCode, valueJson, effectiveFrom, effectiveTo, reason, branchId?)`
- Inserts a historical rule version (backfilling past pricing)
- Validates no date range overlap with existing versions
- Used by Pricing dashboard "add version" feature

### 5.7 `override(ruleCode, entityType, entityId, overrideValueJson, reason)`
- Creates an entity-specific override in `rule_overrides`
- Used for member-specific exceptions (e.g., special fee arrangements)

### 5.8 `getVersionHistory(ruleCode, branchId?)`
- Returns all `business_rules` rows for a code (different effective periods)
- Ordered by effective_from DESC

### 5.9 `clearCache()`
- Resets the in-memory cache array

---

## 6. RuleEngineAdapter

Implements `App\Core\Contracts\RuleEngineInterface` — a thin adapter pattern allowing other modules to depend on the interface rather than the concrete class. Registered in `provides.php` via `ModuleRegistry`.

---

## 7. Events Dispatched

None — rule changes are NOT broadcast as events. Modules always read current values at calculation time.

---

## 8. Events Consumed

None — the Rules module does not listen for events.

---

## 9. Cross-Module Dependencies

### 9.1 Rules module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| (None) | Rules is self-contained — only uses App core (DB, Logger) |

### 9.2 Other modules that IMPORT FROM Rules (RuleEngine):

| Module | What it imports | Key rules used |
|--------|----------------|----------------|
| Pricing | RuleEngine (get, require, update, addHistoricalVersion) | ALL rules via dashboard |
| Members | RuleEngine (WORKING_MEMBER_MIN_AGE, free counts, numbering) | age, children_fee, spouse_fee, financial |
| Children | RuleEngine (child fee rules, age limits) | children_fee, age |
| Spouses | RuleEngine (spouse fee percentages) | spouse_fee |
| Transfers | RuleEngine (separation fee percentages) | separation_fee |
| Installments | RuleEngine (INSTALLMENT_INTEREST_RATE) | financial |
| Fines | RuleEngine (penalty rules, drop thresholds) | penalty |
| Death | RuleEngine (death fee calculations) | death |
| Divorce | RuleEngine (divorce fee calculations) | divorce |
| Foreign | RuleEngine (foreign member fees) | foreign |
| Honorary | RuleEngine (honorary membership rules) | foreign |
| Subscriptions | RuleEngine (overdue fines, subscription rules) | penalty, subscription |
| Payments | RuleEngine (payment lifecycle rules) | financial |
| Carnets | RuleEngine (carnet replacement fee) | financial |
| Seasonal | RuleEngine (seasonal pricing/discounts) | temporary |
| Sports | RuleEngine (sports conversion calculations) | sports_activity |
| Rentals | RuleEngine (rental contract rules) | financial |
| Reservations | RuleEngine (reservation pricing) | financial |
| PlayerAffairs | RuleEngine (player registration) | sports_activity |
| Waiver | RuleEngine (waiver fee calculation) | separation_fee |

---

## 10. Permissions

| Key | Description |
|-----|-------------|
| rules.view | View rules list and history |
| rules.edit | Edit rule values |
| rules.create | Create new rules |
| rules.deactivate | Deactivate rules |

---

## 11. Menu Structure

The Rules module registers a **shared navigation group** ("Rules & Pricing") that includes:
1. Rules Engine (/rules)
2. Pricing (/pricing)
3. Service Catalog (/catalog)
4. Special Discounts (/pricing/special-discounts)

This is registered in `Rules/bootstrap.php` with `order: 720`.

---

## 12. High-Risk Areas

### 12.1 RuleEngine — System-Wide Single Point of Failure
- **Every fee calculation in the system** depends on RuleEngine
- If a required rule is missing: `require()` throws RuntimeException → 500 error
- If a rule has wrong JSON structure: downstream code crashes on missing keys
- Cache invalidation is all-or-nothing (`$cache = []`) — no granular invalidation

### 12.2 Date-Bounded Rules — Gap Risk
- Rules have effective_from/effective_to ranges
- If no rule matches today's date for a given code, `get()` returns null
- `require()` throws — so time gaps between rule versions cause system outages
- The overlap check in `addHistoricalVersion()` prevents overlaps but not gaps

### 12.3 Rule Update Side Effects
- Updating a rule takes IMMEDIATE effect for all new calculations
- No "preview" or "staging" — `saveSection()` from Pricing dashboard directly calls `RuleEngine::update()`
- Existing unpaid bills calculated with old values won't be recalculated automatically
- Rule versions provide rollback data but no automated rollback mechanism

### 12.4 In-Memory Cache Scope
- Cache is per-HTTP-request only (static array)
- No cross-request caching (no Redis/memcache)
- Same rule may be queried dozens of times per page load — cache helps
- But different requests always hit the DB at least once per rule

### 12.5 Override Priority
- `getEffective()` checks overrides FIRST — override always wins
- No UI for managing overrides (only programmatic creation)
- No expiry notification for time-bounded overrides
- Currently 0 overrides in production — feature is built but unused

### 12.6 Category Inconsistency
- `penalties` (2 rules) vs `penalty` (14 rules) — both exist as separate categories
- Some categories have no clear boundary (e.g., `financial` includes form fees AND installment rates)
- Renaming a category requires updating all rows in `business_rules`

---

## 13. Known Patterns & Gotchas

1. **No soft delete**: Rules use `is_active` flag + `effective_to` date — deactivating a rule may break downstream `require()` calls
2. **JSON value structure varies by rule**: Some have `{percentage}`, others `{amount}`, others `{value}` — `getValue()` uses fallback chain
3. **Branch-specific rules**: A rule can have both global (branch_id=NULL) and branch-specific versions — branch is checked first
4. **Version number on business_rules row**: Incremented on each update — also tracked in `rule_versions` table
5. **Update creates new row on date change**: If effectiveFrom differs from current rule, a new `business_rules` row is inserted (old one gets effective_to set)
6. **provides.php registers the module**: This enables DI through `RuleEngineInterface` — but most code still uses `RuleEngine` directly (static calls)
7. **Simulate view exists but limited**: The simulate endpoint renders a view but actual simulation logic is minimal — it shows the rule data for manual inspection

---

## Appendix: Key Rule Codes Reference

### Financial Rules
| Code | Typical Value |
|------|--------------|
| FORM_FEE | `{"form": 500, "stamp": 5, "total": 505}` |
| DEVELOPMENT_FEE | `{"amount": 35}` |
| INSTALLMENT_INTEREST_RATE | `{"percentage": "..."}` |
| FORM_ADDITION_FEE | `{"amount": 570}` |

### Spouse Fee Rules
| Code | Typical Value |
|------|--------------|
| SPOUSE_2ND_FEE | `{"percentage": "10", "annual_flat": "150"}` |
| SPOUSE_3RD_FEE | `{"percentage": "20", "annual_flat": "200"}` |
| SPOUSE_4TH_FEE | `{"percentage": "30", "annual_flat": "300"}` |
| SPOUSE_FOREIGN_FEE | `{"percentage": "..."}` |

### Children Fee Rules
| Code | Typical Value |
|------|--------------|
| INITIAL_FREE_CHILDREN_COUNT | `{"value": 2}` |
| CHILD_INCLUDED_MAX_AGE | `{"value": 18}` |
| CHILD_FEE_AGE_18 | `{"percentage": "10", "type": "regular"}` |
| CHILD_FEE_AGE_19 | `{"percentage": "15", "type": "regular"}` |
| CHILD_FEE_AGE_20 | `{"percentage": "20", "type": "regular"}` |
| CHILD_FEE_AGE_21 | `{"percentage": "15", "type": "temporary"}` |

### Age Rules
| Code | Typical Value |
|------|--------------|
| WORKING_MEMBER_MIN_AGE | `{"value": 21}` |
| CHILD_MAX_AGE | `{"value": 25}` |
| MALE_CHILD_FREEZE_AGE | `{"value": 25}` |

---

## Appendix: Files to Check Before Modifying Rules

1. `app/Modules/Pricing/Services/PricingEngine.php` — Primary consumer of RuleEngine for fee calculations
2. `app/Modules/Pricing/Controllers/PricingDashboardController.php` — Updates rules via dashboard
3. `app/Modules/Members/Services/MembershipRulesService.php` — Uses rules for member eligibility
4. `app/Modules/Children/Services/ChildFeeCalculator.php` — Uses rules for child fees
5. `app/Modules/Spouses/Services/SpouseFeeCalculator.php` — Uses rules for spouse fees
6. `app/Modules/Subscriptions/Services/SubscriptionCalculator.php` — Uses rules for subscription amounts
7. `app/Modules/Subscriptions/Services/OverdueFineApplicator.php` — Uses penalty rules
8. `app/Modules/Transfers/Services/SeparationFeeCalculator.php` — Uses separation fee rules
9. `app/Modules/Fines/Controllers/FineController.php` — Uses penalty rules
10. `app/Core/Contracts/RuleEngineInterface.php` — Interface contract
