# Seasonal Module — Architecture Map

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

---

## 1. Purpose & Responsibilities

The Seasonal module manages **time-limited seasonal memberships** (pool access, facility use) for members typed as 'seasonal'. As of 2026-06-11, seasonal membership is a **primary membership type** selected at creation (not a working member add-on). It:
- Handles dual-nationality pricing (Egyptian in EGP, Foreign in USD)
- Supports family group subscriptions (member + spouses + children + temporary members in one batch)
- Allows inline creation of NEW spouses, children, and temporary members with full data collection (same fields as standalone forms)
- Calculates age-category-based pricing for children
- Applies family discounts (couple, parent+child, family of 5+)
- Applies no-qualification and qualified-member discounts
- Calculates and adds VAT
- Creates payment requests via the Cashier module
- Provides a real-time price preview API endpoint
- Injects seasonal data into the member profile view (via EventBus)

It does **NOT** directly manage:
- Payment processing (delegates to Cashier/PaymentRequestService)
- Payment confirmation (Cashier/bootstrap.php activates seasonal memberships on payment)
- The core member record (Members module owns that)

---

## 2. Directory & File Structure

```
app/Modules/Seasonal/
├── bootstrap.php                          # EventBus listener: injects seasonal data into member profile
├── Routes.php                             # Web routes (4 routes)
├── Controllers/
│   └── SeasonalController.php            # Main controller (index, create, store, pricePreview)
├── Models/
│   └── SeasonalMembership.php            # Model (soft delete, events)
├── Services/
│   └── SeasonalPricingService.php        # Complete pricing engine (prices, discounts, VAT)
└── Views/
    ├── index.php                         # All seasonal memberships (filterable by status)
    └── create.php                        # Family group registration form
```

---

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

### 3.1 `seasonal_memberships` Table (3 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| member_id | bigint unsigned | NO | MUL | FK to members |
| parent_seasonal_id | bigint unsigned | YES | MUL | Self-FK: child records point to parent (member's record) |
| branch_id | bigint unsigned | NO | MUL | FK to branches |
| nationality_type | enum('egyptian','foreign') | NO | MUL | Determines currency and pricing table |
| duration_months | tinyint unsigned | NO | | 3, 6, or 12 |
| person_type | enum('member','spouse','child') | NO | | Who this record is for |
| person_name | varchar(200) | YES | | Denormalized name |
| person_age | tinyint unsigned | YES | | Age at creation |
| age_category | enum('under_15','15_to_21','over_21','over_60') | YES | | Pricing tier |
| start_date | date | NO | MUL | Term start |
| end_date | date | NO | | Term end (start + duration_months) |
| fee_amount | decimal(15,2) | NO | | Base price for this person |
| currency | enum('EGP','USD') | NO | | EGP for egyptian, USD for foreign |
| vat_amount | decimal(15,2) | NO | | Only on parent record, default 0.00 |
| discount_amount | decimal(15,2) | NO | | Only on parent record, default 0.00 |
| discount_type | varchar(100) | YES | | e.g. 'couple', 'family_5', 'parent_child', 'no_qualification' |
| total_amount | decimal(15,2) | NO | | Grand total (on parent) or base_price (on children) |
| fee_receipt_number | varchar(50) | YES | | Populated after payment |
| status | varchar(50) | NO | MUL | 'pending_payment', 'active', etc. |
| carnet_marking | varchar(100) | YES | | Badge text for carnet (e.g. 'seasonal') |
| includes_pool | tinyint(1) | NO | | Default 0 (always 1 in practice) |
| notes | text | YES | | |
| is_archived | tinyint(1) | NO | | Soft delete flag, default 0 |
| archived_at | timestamp | YES | | |
| archived_by | bigint unsigned | YES | | |
| 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 |

**Current data distribution (status):**
- pending_payment: 2
- active: 1

### 3.2 Parent-Child Record Pattern

A family seasonal subscription creates **multiple rows**:
1. **Parent record** (person_type='member', parent_seasonal_id=NULL): holds VAT, discount, grand total
2. **Child records** (person_type='spouse'|'child', parent_seasonal_id=parent.id): individual prices, no VAT/discount

### 3.3 Related Tables

| Table | Owner Module | Relationship |
|-------|--------------|--------------|
| members | Members | member_id FK |
| branches | Branches | branch_id FK |
| qualifications | Members | Loaded for spouse qualification dropdown in inline form |
| spouses | Spouses | Referenced during creation to list available spouses; new spouses created inline |
| children | Children | Referenced during creation to list available children; new children created inline |
| temporary_members | Temporary | New temporary members created inline during seasonal registration |
| payment_requests | Cashier | Created on store, linked via entity_type/entity_id |

---

## 4. Routes

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /seasonal | SeasonalController@index | auth | temp.view |
| GET | /members/{memberId}/seasonal/create | SeasonalController@create | auth | temp.add |
| POST | /members/{memberId}/seasonal | SeasonalController@store | auth, csrf | temp.add |
| GET | /api/seasonal/price-preview | SeasonalController@pricePreview | auth | temp.add |

---

## 5. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `seasonal.created` | `{member_id, seasonal_id, nationality_type, duration_months, total, currency, items_count}` | SeasonalController::store |

---

## 6. Events Consumed

| Event | Listener | Action |
|-------|----------|--------|
| `member.profile_data` | Seasonal/bootstrap.php | Injects seasonal membership history into member profile view |

### External listener (Cashier module):
| Event Context | Action |
|--------------|--------|
| Payment confirmed with type='seasonal_fee' | Cashier/bootstrap.php updates `seasonal_memberships.status` to 'active' and dispatches `seasonal.fee_paid` |

---

## 7. Cross-Module Dependencies

### 7.1 Seasonal module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Rules | RuleEngine (price overrides via SEASONAL_PRICE_* keys, SEASONAL_VAT_RATE) |
| Cashier | PaymentRequestService::createRequest() — creates treasury payment request |

### 7.2 Other modules that REFERENCE Seasonal:

| Module | What it references |
|--------|-------------------|
| Members (bootstrap.php) | Registers the sidebar menu item for /seasonal |
| Members (MembershipRulesService) | Knows about seasonal membership type |
| Cashier (bootstrap.php) | Activates seasonal_memberships on payment confirmation |
| Archive | ArchiveService (cascade archive for seasonal_memberships) |
| Audit | AuditService tracks seasonal_memberships changes |

---

## 8. Permissions

This module reuses Temporary module permissions:

| Key | Usage |
|-----|-------|
| temp.view | View seasonal memberships list |
| temp.add | Create seasonal memberships, access price preview |

No dedicated permissions are registered in bootstrap.php.

---

## 9. Pricing Engine (SeasonalPricingService)

### 9.1 Egyptian Pricing (EGP) — Includes Pool

| Duration | Member | Spouse | Under 15 | 15-21 | Over 21 | Over 60 |
|----------|--------|--------|----------|-------|---------|---------|
| 6 months | 5,500 | 5,500 | 3,500 | 4,500 | 6,500 | 3,500 |
| 12 months | 10,500 | 10,500 | 5,000 | 8,500 | 10,500 | 5,000 |

### 9.2 Foreign Pricing (USD) — Includes Pool

| Duration | Member | Spouse | Under 15 | 15-21 | Over 21 |
|----------|--------|--------|----------|-------|---------|
| 3 months | 100 | 90 | 55 | 65 | 80 |
| 6 months | 180 | 170 | 100 | 120 | 150 |
| 12 months | 300 | 270 | 165 | 195 | 240 |

### 9.3 Discounts (Egyptian only, mutually exclusive)

| Discount Type | Amount | Condition |
|--------------|--------|-----------|
| Family of 5+ | 1,000 EGP | Couple + 3+ children |
| Couple | 1,000 EGP | Member + spouse in same period |
| Parent + child under 15 | 500 EGP | Member + child under 15 |
| No qualification | 10% of subtotal | No family discount AND no higher qualification |

### 9.4 VAT
- Rate: 14% (configurable via `SEASONAL_VAT_RATE`)
- Applied on amount AFTER discounts

### 9.5 Valid Durations
- Egyptian: 6, 12 months
- Foreign: 3, 6, 12 months

---

## 10. Configuration Dependencies

| Rule Engine Key | Purpose |
|----------------|---------|
| SEASONAL_PRICE_{nationality}_{months}_{type}[_{category}] | Price override per combination |
| SEASONAL_VAT_RATE | VAT percentage (default: 14%) |

---

## 11. High-Risk Areas

### 11.1 Payment-Activation is External
- The Seasonal module creates records with status='pending_payment'
- Activation happens in Cashier/bootstrap.php when payment is confirmed
- If the Cashier payment listener changes, seasonal memberships will never activate

### 11.2 Parent-Child Record Integrity
- Grand total/VAT/discount stored ONLY on parent record (parent_seasonal_id=NULL)
- If parent record is deleted/archived independently of children, data becomes orphaned
- No cascading relationship enforced at model level

### 11.3 Denormalized Person Data
- `person_name` and `person_age` are copied at creation time
- If the child's name changes or age is incorrect, the seasonal record retains old data
- Age category pricing is fixed at creation time (no re-calculation on renewal)

### 11.4 No Expiry Enforcement
- There is no cron job that expires seasonal memberships when `end_date` passes
- The `isExpired()` helper is available on the model but nothing automated calls it
- Expired seasonal memberships remain in 'active' status until manually changed

### 11.5 Price Preview vs Store Divergence Risk
- The `pricePreview` endpoint and `store` both call `SeasonalPricingService::calculate()`
- But they build the items array independently — if logic diverges, preview won't match final price

---

## 12. Known Patterns & Gotchas

1. **Menu registered externally**: The Seasonal sidebar menu item is registered by Members/bootstrap.php
2. **EventBus injection**: The bootstrap.php listener injects seasonal data into `member.profile_data` for display on member profile pages
3. **Family batch creation**: A single form submission creates N+1 records (1 parent + N family members)
4. **Inline member creation**: The form creates actual Spouse/Child/TemporaryMember records in their respective tables (full data: NID parsing, passport, DOB, gender, nationality, qualification, contact info, etc.) — not just seasonal_memberships entries
5. **Payment request description includes emoji**: The breakdown stored in payment notes uses emoji characters for formatting
6. **Index limited to 100 rows**: The index query has `LIMIT 100` — large datasets will be truncated
7. **No over_60 for foreign**: Foreign pricing table does not include an over_60 category (member price applies)
8. **Status filter on index**: Supports filtering by status via query parameter `?status=active`
9. **Temporary members priced as children**: Inline temp members use `person_type='child'` for pricing with their age determining the tier
10. **NID auto-parsing in JS**: The inline forms parse Egyptian 14-digit national IDs to extract date_of_birth and gender automatically
