# RULE FILE 03 — Pricing Engine Rules

The pricing engine is the most complex calculation in the system. One wrong rule application = wrong invoice amount = angry parent = refund headache.

---

## Core Principle

**There are NO fixed prices.** Every price is calculated at the moment of sale/enrollment through the pricing engine. A "base price" exists, but it's just the starting point before rules apply.

---

## Base Prices

### The `base_prices` Table Contract

```
id, academy_id, priceable_type, priceable_id (polymorphic — Program, Product, Service),
branch_id (nullable — null means all branches),
amount (bigint — piasters), currency ('EGP'),
effective_from (date), effective_until (date, nullable),
is_active (boolean), created_by, created_at, updated_at
```

### Base Price Rules

1. A product/program can have MULTIPLE base prices (different time periods, different branches)
2. Resolution: find the ONE active base price where `effective_from <= today AND (effective_until IS NULL OR effective_until >= today) AND (branch_id = current_branch OR branch_id IS NULL)`
3. If branch-specific AND global both exist → branch-specific wins (more specific)
4. If NO base price found → system CANNOT proceed. Block the sale. Show error: "لا يوجد سعر محدد لهذا البرنامج"
5. Base prices NEVER overlap for the same priceable + branch combination
6. Changing a base price does NOT affect existing invoices (snapshot principle)

---

## Pricing Rules

### The `pricing_rules` Table Contract

```
id, uuid, academy_id, name, name_ar,
rule_type, conditions (jsonb), adjustment_type, adjustment_value,
priority (integer — higher = applied later),
stackable (boolean — can this combine with other rules?),
max_discount_percent (decimal — cap for this rule),
is_active, effective_from, effective_until (nullable),
applicable_to_type (nullable — polymorphic, specific product/program, null=all),
applicable_to_id (nullable),
branch_id (nullable — null=all branches),
created_by, created_at, updated_at
```

### Rule Types — Exact 13

```
CHECK (rule_type IN (
    'age', 'membership_duration', 'family_size', 'sibling_order',
    'classification', 'enrollment_timing', 'enrollment_volume',
    'seasonal', 'gender', 'branch', 'day_time', 'loyalty', 'custom'
))
```

### Adjustment Types

```
CHECK (adjustment_type IN ('percentage_discount', 'fixed_discount', 'fixed_price', 'percentage_increase'))
```

- `percentage_discount` — subtract X% from current price
- `fixed_discount` — subtract X piasters from current price
- `fixed_price` — override to exact amount (ignores base price)
- `percentage_increase` — add X% (e.g., peak hours surcharge)

### Conditions JSON Structure Per Rule Type

```json
// age: participant's age in years
{"min_age": 4, "max_age": 6}

// membership_duration: months since first enrollment
{"min_months": 12, "max_months": null}

// family_size: siblings enrolled in same academy
{"min_siblings": 2, "max_siblings": null}

// sibling_order: which child is this (1st, 2nd, 3rd...)
{"sibling_order": 3}  // 3rd sibling gets discount

// classification: participant.classification value
{"classifications": ["scholarship", "staff_child"]}

// enrollment_timing: how far before start date they enrolled
{"days_before_start_min": 30}  // early bird

// enrollment_volume: enrolling in N programs simultaneously
{"min_programs": 2}

// seasonal: specific date range
{"start_date": "2026-06-01", "end_date": "2026-08-31"}  // summer

// gender: participant's gender
{"gender": "female"}

// branch: which branch
{"branch_ids": [1, 3]}

// day_time: specific day/time slots
{"days": [0, 1], "start_time": "06:00", "end_time": "09:00"}  // morning

// loyalty: continuous enrollment months
{"min_continuous_months": 6}

// custom: arbitrary JSONB conditions (evaluated by custom logic)
{"condition_key": "referral_code", "condition_value": "FRIEND2026"}
```

---

## Price Resolution Algorithm — THE EXACT 10 STEPS

```
Input: participant, priceable (program/product), branch, date
Output: PriceResult {original, final, appliedRules[], savings}

Step 1: FIND BASE PRICE
  → Query base_prices for this priceable + branch + date
  → If none found: FAIL with error
  → current_price = base_price.amount

Step 2: GATHER APPLICABLE RULES
  → All pricing_rules WHERE:
    - academy_id matches
    - is_active = true
    - effective_from <= date AND (effective_until IS NULL OR effective_until >= date)
    - (applicable_to_type IS NULL OR matches this priceable)
    - (branch_id IS NULL OR branch_id = current branch)
  → Order by priority ASC (lower priority = applied first)

Step 3: EVALUATE CONDITIONS
  → For each rule, evaluate its conditions against the participant context:
    - age rule: calculate participant age from birth_date
    - family_size: count siblings with active enrollment in same academy
    - sibling_order: determine participant's order among enrolled siblings (by enrollment date)
    - membership_duration: months since participant.first_enrollment_date
    - classification: check participant.classification
    - etc.
  → Remove rules whose conditions are NOT met

Step 4: FILTER NON-STACKABLE CONFLICTS
  → If multiple non-stackable rules remain, keep ONLY the one with highest priority
  → Stackable rules all remain

Step 5: APPLY RULES IN PRIORITY ORDER
  → For each remaining rule (ordered by priority ASC):
    - percentage_discount: current_price -= (current_price × rule.adjustment_value / 100)
    - fixed_discount: current_price -= rule.adjustment_value
    - fixed_price: current_price = rule.adjustment_value (OVERRIDES everything before)
    - percentage_increase: current_price += (current_price × rule.adjustment_value / 100)

Step 6: APPLY RULE-LEVEL CAPS
  → After each rule application:
    - If rule.max_discount_percent is set:
      - total_discount_so_far = (base_price - current_price) / base_price × 100
      - If total_discount_so_far > rule.max_discount_percent:
        - current_price = base_price × (1 - max_discount_percent/100)
        - (cap the discount at the rule's max)

Step 7: APPLY PROMOTION/COUPON (if any)
  → Check if a coupon code is applied
  → Validate coupon: active, not expired, usage limit not reached
  → Apply coupon adjustment AFTER all rules

Step 8: APPLY GLOBAL MAXIMUM DISCOUNT
  → Academy-level setting: max_total_discount_percent (default: 50%)
  → If (base_price - current_price) / base_price × 100 > max_total_discount_percent:
    → Cap at max: current_price = base_price × (1 - max_total_discount/100)

Step 9: FLOOR CHECK
  → current_price = max(current_price, 0)
  → Price NEVER goes negative

Step 10: RETURN RESULT
  → PriceResult {
      original_amount: base_price.amount,
      final_amount: current_price (rounded to nearest piaster),
      applied_rules: [{rule_id, rule_name, discount_amount}, ...],
      total_savings: base_price - current_price,
      coupon_applied: coupon_code or null
    }
```

---

## Pricing Edge Cases — HANDLE ALL OF THESE

| Case | Behavior |
|------|----------|
| No base price exists | BLOCK sale. Show error. |
| All rules have expired | Use base price as-is (no discounts) |
| fixed_price rule + percentage_discount rule | fixed_price wins if higher priority. If lower priority, percentage applies AFTER the fixed price set. |
| Discount exceeds price (would go negative) | Floor at 0. Free item. |
| Multiple sibling_order rules (2nd kid discount + 3rd kid discount) | Only the one matching this participant's actual order applies |
| Participant age changes mid-subscription | Price was calculated at enrollment time. Does NOT retroactively change. Next renewal recalculates. |
| Family size increases (new sibling enrolls) | Existing siblings' prices don't change mid-term. Next renewal recalculates. |
| Coupon + rule both give 50% discount | Both apply, subject to global max cap |
| Rule applies to specific program only + one that applies to all | Both can apply if stackable |
| Branch-specific rule AND global rule conflict | Both apply (they're additive). If same rule_type and non-stackable, branch-specific wins. |

---

## Promotions & Coupons

### The `promotions` Table

```
id, uuid, academy_id, name, name_ar,
code (unique per academy), adjustment_type, adjustment_value,
usage_limit (nullable — null=unlimited), usage_count (default 0),
per_person_limit (default 1),
effective_from, effective_until,
min_purchase_amount (nullable), applicable_programs (jsonb, nullable),
is_active, created_by, created_at, updated_at
```

### Coupon Validation Rules (in order)

1. Does the code exist in this academy? → No: "كود الخصم غير صحيح"
2. Is `is_active = true`? → No: "هذا الكود غير مفعل"
3. Is today between `effective_from` and `effective_until`? → No: "هذا الكود منتهي الصلاحية"
4. Is `usage_count < usage_limit`? → No: "تم استخدام هذا الكود الحد الأقصى"
5. Has THIS person used it less than `per_person_limit` times? → No: "لقد استخدمت هذا الكود من قبل"
6. Is the purchase amount >= `min_purchase_amount`? → No: "الحد الأدنى للشراء {amount} ج.م"
7. Is the item in `applicable_programs` (if restricted)? → No: "هذا الكود لا ينطبق على هذا البرنامج"
8. All pass → Apply. Increment `usage_count`. Record in `coupon_usages` table.

---

## Integration Points — Pricing Engine Talks To:

| Module | When | What Happens |
|--------|------|--------------|
| POS | Item added to cart | Engine calculates real-time price, shows discount |
| Enrollment | Participant enrolls in program | Engine determines subscription amount for invoice |
| Invoice Creation | Any invoice item added | Engine provides unit_price (or service provides manual override with audit) |
| Renewal | Subscription renews | Engine recalculates with current rules (price may differ from original) |
| Reporting | Revenue reports | Reports show both original and discounted amounts |
