# Phase 3 — Pricing Engine

## Goal

No product stores a fixed selling price. Every price is CALCULATED at the moment of sale by evaluating all applicable rules. The Pricing Engine is an independent service that any module can call.

---

## Principle: Prices Are Derived, Not Stored

When a receptionist subscribes a participant to a program, the system asks:
> "Given THIS participant, THIS program, THIS branch, THIS date — what is the correct price?"

The answer is computed from:
1. The base price of the service/product
2. All applicable pricing rules (age, membership, family, timing, volume, custom)
3. Rule priority and stacking policy
4. Active promotions and coupons

---

## Module: Base Prices

### Entity: `base_prices`

Every sellable item has a base price — this is the STARTING point before rules modify it.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| academy_id | FK organizations | |
| priceable_type | string | Polymorphic — TrainingProgram, Product, Kit, Service |
| priceable_id | bigint | |
| name | string | Description for receipts — "Monthly subscription", "Registration fee" |
| name_ar | string | |
| amount | bigint | Base price in piasters |
| currency | string(3) | Default 'EGP' |
| billing_cycle | string(20) | CHECK: one_time, monthly, quarterly, semi_annual, annual, per_session |
| effective_from | date | When this price becomes active |
| effective_until | date | Nullable — null = no end |
| branch_id | FK branches | Nullable — null = all branches |
| is_active | boolean | Default true |
| created_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |

### Base Price Rules
- A priceable can have MULTIPLE base prices (different billing cycles, different branches, different periods)
- The system picks the one that matches: branch + billing cycle + current date within effective range
- If multiple match: most specific wins (branch-specific > all-branches, shorter date range > longer)
- Zero-amount base prices are valid (free programs, complimentary sessions)
- Base prices are HISTORICAL — changing the base price creates a new record, doesn't edit the old one

---

## Module: Pricing Rules

### Entity: `pricing_rules`

A rule that modifies the base price when its conditions are met.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| name | string | Human-readable name |
| name_ar | string | |
| description | text | Nullable |
| rule_type | string(30) | CHECK: see types below |
| target_type | string(20) | CHECK: all, program, product, kit, service, category |
| target_id | bigint | Nullable — null if target_type='all' |
| adjustment_type | string(10) | CHECK: percentage, fixed, override |
| adjustment_value | bigint | Percentage (×100 for precision) OR fixed amount in piasters OR override amount |
| adjustment_direction | string(10) | CHECK: discount, surcharge |
| conditions | jsonb | Rule-specific conditions (see below) |
| priority | integer | Lower number = higher priority (evaluated first) |
| stackable | boolean | Default true — can stack with other rules |
| max_discount_percent | integer | Nullable — cap: "never exceed 50% total discount" |
| min_final_amount | bigint | Nullable — floor: "never go below 100 EGP" |
| requires_approval | boolean | Default false — large discounts may need manager approval |
| effective_from | date | Nullable |
| effective_until | date | Nullable |
| branch_id | FK branches | Nullable — null = all branches |
| max_uses | integer | Nullable — total uses allowed before rule expires |
| current_uses | integer | Default 0 |
| is_active | boolean | Default true |
| created_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |

### Rule Types and Their Conditions

#### 1. `age_based`
Discount/surcharge based on participant's age.
```json
{
  "age_min": 4,
  "age_max": 6,
  "age_calculation": "at_enrollment_date"  // or "current_age"
}
```
Example: Under-6 program costs 20% less.

#### 2. `membership_status`
Based on participant's relationship with the academy.
```json
{
  "statuses": ["club_member", "academy_member"],
  "membership_types": ["gold", "platinum"]
}
```
Example: Club members get 10% off all programs.

#### 3. `family_size`
Based on how many siblings are already enrolled.
```json
{
  "sibling_count_min": 2,
  "sibling_count_max": null,
  "count_method": "active_enrollments"  // or "registered_participants"
}
```
Example: 3rd child gets 30% off.

#### 4. `sibling_order`
Different discount per sibling position.
```json
{
  "position": 2,  // 2nd child
  "count_method": "enrollment_date_order"
}
```
Example: 1st child full price, 2nd 15% off, 3rd 25% off, 4th+ 40% off.

#### 5. `participant_classification`
Based on tags/classifications assigned to the participant.
```json
{
  "classifications": ["veteran_child", "staff_child", "scholarship"]
}
```
Example: Staff children get 50% off.

#### 6. `enrollment_timing`
Early bird / late registration pricing.
```json
{
  "days_before_start": 30,
  "comparison": "greater_than"  // or "less_than"
}
```
Example: Register 30+ days early → 10% early bird discount.

#### 7. `enrollment_volume`
Multi-program discounts.
```json
{
  "min_programs": 2,
  "max_programs": null,
  "count_scope": "active"  // count only active enrollments
}
```
Example: 2nd program 10% off, 3rd program 20% off.

#### 8. `seasonal`
Time-based pricing adjustments.
```json
{
  "season_name": "summer",
  "months": [6, 7, 8]
}
```
Example: Summer programs 15% cheaper (academy is less full).

#### 9. `gender_based`
When programs are priced differently by gender (rare but exists).
```json
{
  "gender": "female"
}
```

#### 10. `branch_based`
Different pricing per branch (downtown vs suburb).
```json
{
  "branch_ids": [2, 3]
}
```

#### 11. `day_time_based`
Off-peak pricing.
```json
{
  "days": [0, 1, 2],  // Sun, Mon, Tue
  "time_start": "08:00",
  "time_end": "14:00"
}
```
Example: Morning sessions 20% cheaper than evening.

#### 12. `loyalty`
Based on how long the participant has been enrolled.
```json
{
  "months_enrolled_min": 12,
  "months_enrolled_max": null
}
```
Example: 1+ year members get 5% loyalty discount.

#### 13. `custom`
Catch-all for academy-defined rules that don't fit above categories.
```json
{
  "custom_field": "referral_source",
  "custom_value": "social_media"
}
```

---

## Module: Promotions

### Entity: `promotions`

A time-limited marketing campaign that applies pricing adjustments.

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| uuid | uuid UNIQUE | |
| academy_id | FK organizations | |
| name | string | |
| name_ar | string | |
| description | text | Nullable — marketing copy |
| banner_image | string | Nullable — for POS/website display |
| adjustment_type | string(10) | CHECK: percentage, fixed, override |
| adjustment_value | bigint | |
| target_type | string(20) | CHECK: all, program, product, kit, category |
| target_id | bigint | Nullable |
| starts_at | timestamp | |
| ends_at | timestamp | |
| max_redemptions | integer | Nullable — total across all customers |
| current_redemptions | integer | Default 0 |
| max_per_customer | integer | Nullable — limit per person |
| requires_coupon | boolean | Default false — auto-applied vs requires code |
| stackable_with_rules | boolean | Default false — if false, replaces rule-based discounts |
| branch_id | FK branches | Nullable |
| status | string(20) | CHECK: draft, scheduled, active, expired, cancelled |
| created_by | FK users | |
| created_at | timestamp | |
| updated_at | timestamp | |

### Entity: `coupon_codes`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| promotion_id | FK promotions | |
| code | string(20) | UNIQUE per academy |
| max_uses | integer | Nullable |
| current_uses | integer | Default 0 |
| assigned_to_type | string | Nullable — polymorphic (specific customer) |
| assigned_to_id | bigint | Nullable |
| expires_at | timestamp | Nullable — can expire before promotion ends |
| is_active | boolean | Default true |
| created_at | timestamp | |

### Entity: `coupon_redemptions`

| Column | Type | Rules |
|--------|------|-------|
| id | bigint PK | |
| coupon_id | FK coupon_codes | |
| customer_type | string | Polymorphic |
| customer_id | bigint | |
| invoice_id | FK invoices | |
| discount_amount | bigint | Actual discount applied |
| redeemed_at | timestamp | |

---

## Pricing Resolution Algorithm

```
PricingService::calculate(Priceable $item, Context $context): PriceResult

Context includes:
- participant (age, gender, classifications, enrollment count, family size, sibling position, loyalty months)
- branch
- date/time
- billing_cycle
- coupon_code (optional)

Algorithm:
1. Find applicable base_price for this item + branch + date + billing_cycle
   → If none found: throw PriceNotFoundException (don't silently use 0)

2. Start with base_amount = base_price.amount

3. Load all active pricing_rules where:
   - academy_id matches
   - (target_type = 'all' OR target matches this item)
   - (branch_id is null OR matches context.branch)
   - effective date range includes today
   - max_uses not reached
   - is_active = true

4. Evaluate each rule's conditions against the context:
   - age_based: check participant.age against min/max
   - membership_status: check participant.classifications
   - family_size: count siblings via guardian links
   - sibling_order: determine position via enrollment dates
   - enrollment_timing: days between now and program start
   - enrollment_volume: count active programs
   - seasonal: check current month
   - etc.

5. Collect all PASSING rules. Sort by priority (ascending = highest priority first).

6. Apply rules:
   a. If a non-stackable rule passes → it REPLACES all others (only one applies)
   b. If multiple stackable rules pass → apply in priority order:
      - percentage rules: each applies to ORIGINAL base (not cascading)
      - fixed rules: subtracted from running total
      - override rules: replace the total entirely (highest priority override wins)

7. After all rules applied, check constraints:
   - If total discount exceeds any rule's max_discount_percent → cap it
   - If final amount < any rule's min_final_amount → use the floor
   - If final amount < 0 → set to 0 (never negative)

8. Check for active promotion (auto-applied, not requiring coupon):
   - If promotion exists and stackable_with_rules=true → apply on top
   - If promotion exists and stackable_with_rules=false → compare: use whichever gives bigger discount

9. If coupon_code provided:
   - Validate coupon: active, not expired, uses remaining, not already used by this customer
   - Apply promotion discount via the coupon's linked promotion
   - Record redemption intent (committed on invoice creation)

10. Return PriceResult:
    - base_amount
    - rules_applied: [{rule_id, rule_name, discount_amount}]
    - promotion_applied: {promotion_id, name, discount_amount} or null
    - coupon_applied: {coupon_id, code, discount_amount} or null
    - total_discount
    - final_amount
    - breakdown (human-readable array for receipt)
```

---

## PriceResult Object

```php
class PriceResult
{
    public int $baseAmount;
    public int $finalAmount;
    public int $totalDiscount;
    public array $appliedRules;      // [{id, name, type, adjustment, amount_saved}]
    public ?array $appliedPromotion; // {id, name, amount_saved}
    public ?array $appliedCoupon;    // {id, code, amount_saved}
    public array $breakdown;         // Human-readable line items for receipt
    public string $currency;
    
    public function toInvoiceItemMeta(): array; // Store in invoice_item.metadata for audit
}
```

---

## Edge Cases

1. **No base price found:** System REFUSES to create the invoice item — not silently uses 0. Requires admin to set price.
2. **Multiple rules give 100%+ discount:** Floor at min_final_amount or 0. Never go negative.
3. **Sibling detection across guardians:** Child A has guardian X, Child B has guardian Y. But X and Y are BOTH linked to Child A and B. Sibling detection uses guardian links, not just "same guardian."
4. **Age changes during enrollment:** Age-based rules use `age_at_enrollment_date` by default. Renewal uses `current_age`. Configurable per rule.
5. **Rule changes mid-subscription:** Existing invoices keep their original price. New invoices (renewals) use current rules. No retroactive repricing.
6. **Coupon for specific customer:** Coupon assigned to a person → only that person can use it. Used for scholarship or VIP offers.
7. **Coupon limit per customer:** Set max_per_customer=1 → can only use this promotion once ever.
8. **Stackable vs non-stackable conflict:** If a 40% "scholarship" rule (non-stackable) passes, AND a 10% "early bird" rule (stackable) passes → only scholarship applies (non-stackable wins, others discarded).
9. **Override price:** A rule with adjustment_type='override' sets the price to a specific amount regardless of base. Used for "VIP fixed price" scenarios.
10. **Branch-different pricing:** Downtown branch base price = 2000, suburb branch = 1500. Rules can ALSO differ per branch. Fully independent.
11. **Seasonal pricing expiration:** Summer promotion ends Sept 1. Participant registers Aug 30 for Sept 15 start. Rule says "enrollment_date" not "program_start_date" → they get the discount.
12. **Family size with frozen/withdrawn members:** Does a frozen sibling count? Configurable per rule: `count_method: active_enrollments` (no) vs `registered_participants` (yes).
13. **Promotion max_redemptions reached during multi-user POS:** Use database locks. Decrement max BEFORE invoice creation. If invoice cancelled → increment back.
14. **Price for renewal vs new enrollment:** Some academies charge less for renewals. Implement as: rule_type=`loyalty` with months_enrolled_min=1 (any existing member renewing).

---

## Pricing Display in UI

When creating an invoice or viewing a program:
- Show base price (crossed out if discounted)
- Show each applied rule as a line: "خصم الأخ الثاني (-15%): -300 ج.م"
- Show final price prominently
- If approval required (large discount): show "Pending Approval" badge

---

## Integration Points

| Module | How It Uses Pricing |
|--------|-------------------|
| Invoice creation | Calls `PricingService::calculate()` for each line item |
| POS | Real-time price display as items are scanned/selected |
| Program listing | Shows "starting from" price (lowest possible after common discounts) |
| Enrollment form | Shows calculated price for THIS participant before confirming |
| Renewal process | Recalculates with loyalty/renewal rules applied |
| Reports | Revenue projections use current pricing rules |
