# Subscriptions Module — Architecture Map

> **Last updated:** 2026-07-18 (added syncForMember + member.activated listener; production backfill of missing 2026/2027 rows)
> **Status:** Living document — incrementally updated as new information is discovered

---

## 1. Purpose & Responsibilities

The Subscriptions module manages **annual membership subscription fees** for the club. It handles:
- Batch generation of yearly subscription records for all active members and their dependents
- Individual subscription payment collection (FIFO: oldest year must be fully paid before newer years)
- On-demand fine recalculation per member (triggered on page load via `OverdueFineApplicator::applyForMember()`)
- Overdue detection and late fine calculation (progressive penalties)
- Subscription exemptions (with reason tracking)
- Membership drop enforcement (5+ consecutive unpaid years)
- Reinstatement eligibility checking
- Financial year-based tracking (July to June cycle)

It does **NOT** directly manage:
- Payment processing (delegates to Payments module's PaymentService)
- Member status changes beyond dropping for non-payment
- Activity-specific subscriptions (handled by SportsActivity/ActivitySubscriptions modules)
- Installment plans for subscription debts (Installments module)

---

## 2. Directory & File Structure

```
app/Modules/Subscriptions/
├── bootstrap.php                              # Permission registration + dependent activation listeners
├── Routes.php                                 # 6 web routes
├── Controllers/
│   └── SubscriptionController.php            # All CRUD + batch operations
├── Models/
│   └── Subscription.php                      # Query model (no soft delete, with events)
├── Services/
│   ├── SubscriptionGenerator.php             # Batch generation for a financial year
│   ├── SubscriptionCalculator.php            # Late fine calculation + reinstatement check
│   ├── OverdueFineApplicator.php             # Cron: apply fines + on-demand per-member fine calc
│   └── SubscriptionSyncService.php           # Real-time: adds FY row when dependent becomes active
└── Views/
    ├── index.php                             # Paginated list with filters (year, status, person_type)
    ├── batch-generate.php                    # Admin form to trigger batch generation
    └── member-subscriptions.php              # Year-grouped display with FIFO, fine breakdowns, totals
```

---

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

### 3.1 `subscriptions` Table (221 rows for 2026/2027 FY as of 2026-07-03)

| Column | Type | Nullable | Key | Default | Notes |
|--------|------|----------|-----|---------|-------|
| id | bigint unsigned | NO | PRI | auto_increment | |
| member_id | bigint unsigned | NO | MUL | | FK to members |
| financial_year | varchar(10) | NO | MUL | | Format: "2025/2026" |
| person_type | varchar(50) | NO | MUL | | member/spouse/child/temporary |
| person_id | bigint unsigned | YES | | | FK to respective person table |
| person_name | varchar(200) | YES | | | Denormalized name for display |
| base_amount | decimal(15,2) | NO | | 0.00 | Rate without dev fee |
| development_fee | decimal(15,2) | NO | | 0.00 | Only for member type (35 EGP) |
| discount_amount | decimal(15,2) | NO | | 0.00 | Any applied discount |
| total_amount | decimal(15,2) | NO | | 0.00 | base - discount (dev fee separate) |
| paid_amount | decimal(15,2) | NO | | 0.00 | When fully paid: total + fine + dev_fee (member row) |
| fine_amount | decimal(15,2) | NO | | 0.00 | Late payment fine |
| status | varchar(50) | NO | MUL | pending | pending/paid/overdue/exempt |
| paid_at | timestamp | YES | | | When fully paid |
| payment_id | bigint unsigned | YES | MUL | | FK to payments |
| exempted_by | bigint unsigned | YES | | | FK to employees |
| exemption_reason | text | YES | | | Required for exemptions |
| created_at | timestamp | NO | | CURRENT_TIMESTAMP | |
| updated_at | timestamp | NO | | CURRENT_TIMESTAMP | on update |
| created_by | bigint unsigned | YES | | | FK to employees |
| updated_by | bigint unsigned | YES | | | FK to employees |

**Current data distribution (2026/2027 FY, 221 rows):**
- 66 member @ 492.00
- 54 spouse @ 492.00
- 90 child @ 222.00
- 11 temporary @ 222.00

**Financial years with data:** 2020/2021 through 2026/2027 (7 years)

### 3.2 Key Indexes

- `member_id` — frequent lookups per member
- `financial_year` — filter by year
- `person_type` — filter by dependent type
- `status` — filter active/overdue
- `payment_id` — link to payment record

---

## 4. Routes

| Method | Path | Handler | Middleware | Permission |
|--------|------|---------|------------|------------|
| GET | /subscriptions | index | auth | subscription.view |
| GET | /subscriptions/batch-generate | batchGenerateForm | auth | subscription.generate_batch |
| POST | /subscriptions/batch-generate | batchGenerate | auth, csrf | subscription.generate_batch |
| GET | /members/{memberId}/subscriptions | memberSubscriptions | auth | subscription.view |
| POST | /subscriptions/{id}/pay | pay | auth, csrf | subscription.collect |
| POST | /subscriptions/{id}/exempt | exempt | auth, csrf | subscription.exempt |

---

## 5. Core Business Flows

### 5.1 Annual Batch Generation (SubscriptionGeneratorJob / SubscriptionGenerator)

```
1. Triggered: Cron (July 1-7 auto) OR manual via /subscriptions/batch-generate
2. For each active member (status='active', excluding exempt types from `subscription_rate_overrides`):
   a. Skip if subscription already exists for this member + FY (person_type='member') — dedup guard
   b. Create member subscription: base_amount + dev_fee (35 EGP)
   c. For each active spouse: skip if (member+FY+person_type='spouse'+person_id) already exists, then insert
   d. For each active child: skip if (member+FY+person_type='child'+person_id) already exists, then insert
   e. For each active temp member: skip if (member+FY+person_type='temporary'+person_id) already exists, then insert
3. Exempt types loaded dynamically from `subscription_rate_overrides` table (fallback: honorary, seasonal)
4. Rates loaded from service_catalog (year-specific if available, fallback to generic)
5. Year-specific discount/increase from SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule
```

**Fix applied 2026-06-10:** Steps c/d/e previously had no dedup guard (only the member row in step a was
guarded). Re-running batch generation on the same FY would create duplicate subscription rows for dependents.
Each dependent now checks for an existing row before inserting.

**Fix applied 2026-07-03 (getRate truthiness bug):** `getRate()` returned `'0.00'` for missing/zero 
service_catalog rows. PHP's `?:` operator treats `'0.00'` as truthy, preventing fallback to generic 
service codes. All 204 initially generated subscriptions had `base_amount=0.00`. Fixed by:
1. `getRate()` now returns `''` for missing/zero rows (falsy, triggers fallback)
2. Hard-coded fallback rates as last resort: member/spouse=492, child/temporary=222
3. All 204 bad rows manually corrected via UPDATE on production DB.

### 5.1b Real-Time Member + Dependent Sync (SubscriptionSyncService) — Added 2026-07-18

When a member activates or a dependent is added, subscription rows are automatically inserted
for the **current financial year**. Two entry points in the service:

#### `syncForMember(memberId)` — called on `member.activated`
Creates the member's own FY subscription row plus all currently-active dependents.
Used when a brand-new member activates mid-year (after the July batch already ran).

#### `syncForDependent(personType, personId, memberId)` — called on dependent activation
Adds a single dependent's FY row. Used when a dependent is added to an already-active member.

```
Triggers (all in Subscriptions/bootstrap.php):

  member.activated          → syncForMember(memberId)
                              — creates member row + all active dependents

  spouse.fee_paid           → syncForDependent('spouse', spouseId, memberId)
  child.fee_paid            → syncForDependent('child',  childId,  memberId)
  temporary.fee_paid        → syncForDependent('temporary', tempId, memberId)

  spouse.added   (fee=0)   → syncForDependent('spouse', ...)
  child.added    (fee=0)   → syncForDependent('child', ...)
  temporary.added(fee=0)   → syncForDependent('temporary', ...)

syncForDependent logic:
  1. Guard: member must be active + not an exempt membership type (honorary/seasonal)
  2. Dedup: skip if subscriptions row already exists (member_id + FY + person_type + person_id)
  3. Resolve person name from the dependent's table
  4. Resolve rate via service_catalog (year-specific code → generic code → hard fallback)
  5. Apply year-specific discount if SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule exists
  6. INSERT pending subscription row
  7. Log success or catch + log error (never throws — safe in event handler context)
```

**Idempotent:** safe to re-fire; the dedup guard prevents double rows.

**Production backfill (2026-07-18):** 5 rows were created manually via targeted script
(2 member rows + 2 child rows + 1 temp row). Remaining gaps in audit were all explained
by ineligible members (archived, deceased, waived, not yet active).

### 5.2 Subscription Payment (with FIFO enforcement)

```
1. POST /subscriptions/{id}/pay
2. FIFO CHECK: query for any subscription with older financial_year still pending/overdue
   - If found: reject with error "يجب سداد اشتراكات السنة الأقدم أولاً"
   - financial_year format 'YYYY/YYYY' sorts lexicographically correctly
3. Calculate remaining: total_amount + fine_amount - paid_amount
4. Call PaymentService::processPayment() with:
   - payment_type = 'annual_subscription'
   - related_entity_type = 'subscriptions'
5. Update subscription: status='paid', paid_at, payment_id
6. Dispatch: subscription.paid event
```

**Added 2026-06-12:** FIFO enforcement ensures the oldest unpaid year is paid first.
Business rule: "العضوية بتتجدد بكل محتوياتها" — all people in a year must be cleared before newer years.

**Fixed 2026-06-13:** Dev fee (35 EGP, member row only) is now included in paid_amount when marking as paid.
Formula: `paid_amount = total_amount + fine_amount + development_fee`. The `payYear()` method adds dev_fee
to the member row only (not spouse/child rows). View calculates remaining as
`(total_amount + fine + dev_fee) - paid_amount`, so paid rows correctly show 0 remaining.

### 5.2b On-Demand Fine Recalculation (OverdueFineApplicator::applyForMember)

```
1. Called on page load of /members/{id}/subscriptions (not via cron)
2. SubscriptionCalculator::calculateLateFine() computes fines for this member
3. For each year detail (skipping grace period): UPDATE fine_amount + status='overdue'
4. Returns full calculation array (avoids double-calculating)
5. Idempotent: safe to call multiple times (same inputs = same output)
```

**Added 2026-06-12:** Ensures fines are always current when the page is viewed, regardless of cron timing.

### 5.3 Overdue Fine Application (OverdueFineJob — cron, runs from October)

```
1. Mark unpaid subscriptions (financial_year < current FY) as status='overdue'
   (respects grace period — current FY subscriptions stay 'pending')
2. Delegates to OverdueFineApplicator::run() which:
   a. For each active member with past-due subscriptions:
      - SubscriptionCalculator::calculateLateFine() computes progressive fines
      - distributeFineForYear() proportionally splits fine across subscription rows
   b. Drops members with 5 CONSECUTIVE unpaid years (not just any 5 years)
3. Calls OverdueFineApplicator::expireReinstatements():
   - Members dropped > REINSTATEMENT_WINDOW months ago → status='permanently_dropped'
```

### 5.4 Late Fine Calculation Logic (SubscriptionCalculator)

```
- Grace period: first 3 months of FY (July-September) = no fine (SUB_GRACE_MONTHS rule, default 3)
  Extended grace: 4 months if trustees council approves (SUB_EXTENDED_GRACE_MONTHS rule, default 4)
- Fine is % of TOTAL subscription amount (not remaining), keyed by years_overdue + 1:
  - years_overdue = 0 (current FY, after grace): LATE_SUB_FINE_YEAR_1 = 10%
  - years_overdue = 1 (1 full year late):        LATE_SUB_FINE_YEAR_2 = 50%
  - years_overdue = 2 (2 full years late):       LATE_SUB_FINE_YEAR_3 = 100%
  - years_overdue = 3 (3 full years late):       LATE_SUB_FINE_YEAR_4 = 200%
  - years_overdue = 4 (4 full years late):       LATE_SUB_FINE_YEAR_5 = 300%
  - capped at LATE_SUB_FINE_MAX_YEARS (default 5)
- After LATE_SUB_DROP_YEARS (default 5) consecutive years overdue: member is dropped
```

**Data fix required — Phase_89_001 migration:** The original seed (Phase_05_001) set
YEAR_1=100%, YEAR_2=200%, YEAR_3=300% with no YEAR_4/YEAR_5.  The correct escalation
is 10/50/100/200/300%.  Run `php cli.php migrate` to apply Phase_89_001 which sets
the correct values idempotently via ON DUPLICATE KEY UPDATE.

### 5.5 Reinstatement Check (SubscriptionCalculator::canReinstate)

```
- Only for members with status='dropped'
- Window: REINSTATEMENT_WINDOW months (default: 12) from drop date
- Returns: eligibility, total debt (subscriptions + fines), days remaining
```

### 5.6 Permanent Drop (OverdueFineApplicator::expireReinstatements)

```
- Members who remain 'dropped' past the reinstatement window
- Status changes to 'permanently_dropped'
- Dispatches: member.permanently_dropped event
```

---

## 6. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `subscription.paid` | `{subscription_id, member_id, year, amount}` | SubscriptionController::pay |
| `member.dropped` | `{member_id, reason, years_unpaid}` | OverdueFineApplicator, OverdueFineJob |
| `member.permanently_dropped` | `{member_id, reason}` | OverdueFineApplicator::expireReinstatements |

---

## 7. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `subscription.paid` | Accounting | Auto-post journal entry (revenue recognition) |
| `subscription.paid` | Accounting (StatementIntegrationService) | Update member statement |
| `member.dropped` | Accounting | Write off AR for membership |

---

## 8. Cross-Module Dependencies

### 8.1 Subscriptions module IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Payments | PaymentService::processPayment() — actual money processing |
| Rules | RuleEngine — fine rates, grace periods, max years, dev fee |
| ServiceCatalog | ServicePrice — annual subscription rates by code |
| Members | BoardOfferService (via SubscriptionGenerator, year adjustments) |

### 8.2 Other modules that IMPORT FROM Subscriptions:

| Module | What it imports |
|--------|----------------|
| Payments | BalanceCalculator queries subscriptions table for unpaid totals |
| Members | RetroactiveMembershipService inserts subscriptions directly into table |
| Members | ReportController queries subscription status for reports |
| Reports | ReportEngine::overdueSubscriptionsReport() |
| Dashboard | DashboardDataService queries overdue subscription counts |
| Carnets | CarnetPrintService checks subscription status for card eligibility |

---

## 9. Permissions

| Key | Description |
|-----|-------------|
| subscription.view | View subscription list and member subscription history |
| subscription.collect | Process a subscription payment |
| subscription.exempt | Exempt a member from a subscription (requires reason) |
| subscription.generate_batch | Trigger batch generation for a financial year |

---

## 10. Background Processes (Cron)

| Job | File | Schedule | What it does |
|-----|------|----------|--------------|
| SubscriptionGeneratorJob | cron/jobs/SubscriptionGeneratorJob.php | July 1-7 only | Auto-generates subscriptions for new FY |
| OverdueFineJob | cron/jobs/OverdueFineJob.php | October onwards (month >= 10) | Marks overdue, applies fines, drops members |

**Note:** `OverdueFineJob` now delegates entirely to `OverdueFineApplicator::run()` for fine/drop logic. The cron job only handles marking subscriptions as overdue; all calculation and distribution is in the service class. No duplicate logic exists.

**Cron Infrastructure (added 2026-07-03):**
- Docker container now installs `cron` package via Dockerfile
- `docker/entrypoint.sh` installs crontab and starts cron daemon before Apache
- Crontab: `0 * * * * cd /var/www/html && php cli.php cron >> storage/logs/cron.log 2>&1`
- `SubscriptionGeneratorJob` delegates to `SubscriptionGenerator::generateForYear()` — the same service used by manual batch-generate
- Job checks if ANY rows exist for the FY before generating (skips entirely if count > 0)

---

## 11. Configuration Dependencies

| Config Source | Key | Purpose |
|--------------|-----|---------|
| service_catalog | SVC_ANNUAL_MEMBER | Member base subscription rate (492.00) |
| service_catalog | SVC_ANNUAL_SPOUSE | Spouse subscription rate (492.00) |
| service_catalog | SVC_ANNUAL_CHILD | Child subscription rate (222.00) |
| service_catalog | SVC_ANNUAL_TEMP | Temporary member subscription rate (222.00) |
| service_catalog | SVC_ANNUAL_MEMBER_{YEAR} | Year-specific override (optional) |
| Rules | DEVELOPMENT_FEE | Dev fee amount (35.00 EGP, members only) |
| Rules | LATE_SUB_FINE_YEAR_{N} | Fine percentage for year N overdue |
| Rules | LATE_SUB_FINE_MAX_YEARS | Max years to apply fines (default: 5) |
| Rules | LATE_SUB_DROP_YEARS | Years before dropping membership (default: 5) |
| Rules | SUB_GRACE_MONTHS | Grace period before fines apply (default: 3) |
| Rules | REINSTATEMENT_WINDOW | Months allowed to reinstate after drop (default: 12) |
| Rules | SUBSCRIPTION_YEAR_ADJUSTMENT_{YEAR} | Year-specific discount percentage |

---

## 12. Financial Year Convention

```
Financial year runs July to June:
- If month >= 7: FY = thisYear/(thisYear+1) → e.g. "2025/2026"
- If month < 7: FY = (lastYear)/thisYear → e.g. "2024/2025"

CRITICAL: Subscription system started in 2023/2024.
No subscriptions should exist before this year.
```

**Subscription rates by year:**
| FY | Member/Spouse Base | Child/Temp Base | Discount | Net (M/S) | Net (C/T) |
|----|-------------------|-----------------|----------|-----------|-----------|
| 2023/2024 | 410.00 | 185.00 | 50% | 205.00 | 92.50 |
| 2024/2025 | 410.00 | 185.00 | — | 410.00 | 185.00 |
| 2025/2026+ | 492.00 | 222.00 | — | 492.00 | 222.00 |

**Development fee:** 35.00 EGP (member type only, non-discountable)

---

## 13. High-Risk Areas

### 13.1 Batch Generation Idempotency
- Checks for existing member subscription before generating — but checks per-member only
- If interrupted mid-batch: some members have subscriptions, others don't
- No rollback mechanism for partial generation
- Multiple years can be generated (retroactive via manual batch-generate form)

### 13.2 Fine Calculation Consistency
- Fine is calculated on `total_amount` (full subscription), not remaining unpaid
- Single implementation: `OverdueFineApplicator` (service) used by both cron and controller
- Uses `SubscriptionCalculator::calculateLateFine()` then `distributeFineForYear()` for proportional split
- If rules change mid-year, previously applied fines are not retroactively recalculated

### 13.3 Member Drop (Destructive)
- Dropping a member for non-payment is **irreversible after reinstatement window**
- Drop event triggers accounting write-offs
- No confirmation UI — happens automatically in cron
- Drop check verifies 5 CONSECUTIVE unpaid years (not just count >= 5)

### 13.4 Payment Amount Calculation
- `pay()` calculates remaining as: `total_amount + fine_amount - paid_amount`
- If fine_amount changes between page load and payment submission, amount may be stale
- No partial payment support — always pays full remaining

### 13.5 Cron Job Behavior
- `OverdueFineJob` only marks subscriptions overdue when `financial_year < currentFY` (respects grace)
- All fine logic is in `OverdueFineApplicator` (single source of truth)
- Reinstatement expiry runs every cron cycle
- The service class respects the grace period via `SubscriptionCalculator`
- Risk of inconsistent behavior depending on which path executes

---

## 14. Known Patterns & Gotchas

1. **No soft delete**: Subscriptions are never archived — status tracks lifecycle
2. **Denormalized person_name**: Stored at generation time, not updated if member name changes
3. **person_id polymorphism**: `person_id` references different tables based on `person_type` (members, spouses, children, temporary_members)
4. **Development fee only for members**: All dependent types get `development_fee = 0.00`
5. **Retroactive entries bypass generator**: `RetroactiveMembershipService` inserts directly into subscriptions table with backdated timestamps
6. **Menu registered via Members module**: Subscription menu items appear under the Members sidebar group
7. **No API endpoints**: All interaction is web-only (no REST API for external systems)
8. **Exempt status is terminal**: Once exempted, a subscription cannot be un-exempted without direct DB intervention
9. **PHP truthiness hazard in getRate()**: `'0.00' ?: fallback` never triggers because PHP treats non-empty strings as truthy. Fixed: `getRate()` returns `''` for missing/zero rows, plus hard fallback defaults.
10. **service_catalog date filtering**: `getRate()` filters by `effective_from <= today AND (effective_to IS NULL OR effective_to >= today)`. Year-specific codes (e.g. `SVC_ANNUAL_TEMP_2026`) may not exist — the generic code is the reliable fallback.

---

## 15. Data Migration (SubscriptionDataMigration) — Updated 2026-07-04

A rerunnable migration service (`app/Modules/Subscriptions/Services/SubscriptionDataMigration.php`) that ensures complete, correct subscription coverage for all members.

**CLI Commands:**
```bash
php cli.php subscriptions:migrate          # Run the full migration
php cli.php subscriptions:migrate --dry-run # Preview without changes
php cli.php subscriptions:validate         # Verify data integrity
```

**What it does (6 phases):**
0. **Remove pre-system subscriptions**: Delete unpaid subs before 2023/2024 (system didn't exist). Paid records preserved as historical.
1. **Fix orphaned person_ids**: Match by name (including archived records), or delete if no dependent found.
2. **Remove duplicates**: Exact (member_id + FY + person_type + person_id) — keeps lowest ID.
3. **Fix amounts and discounts**: Ensure correct base_amount, discount_amount (50% for 2023/2024), total_amount per year.
4. **Create missing subscriptions**: For each active member + all dependents, from join date (capped at 2023/2024) to present.
5. **Reset and apply fines**: Clear all fines, recalculate from scratch using SubscriptionCalculator.

**Key business rules enforced:**
- First FY = 2023/2024 (subscription system inception)
- 2023/2024 gets 50% discount on base rate (from SUBSCRIPTION_YEAR_ADJUSTMENT_2023 rule)
- No other year has a discount currently
- Fines start from 2023/2024, progressive: 10%/50%/100%/200%/300%

**Results from production run (2026-07-04):**
- 255 pre-system subscriptions removed, 130 discount corrections, 149 fines recalculated
- Final count: 885 total subscriptions (2023/2024 through 2026/2027)
- 66 members, 53 spouses, 86-89 children, 1-11 temporary — all matching active dependents
- Validation: ALL CLEAN

**Root cause fixed (2026-07-04)**: `RetroactiveMembershipService` previously fell back to `$memberId` for unmapped dependents — now resolves by name from the dependent table, or skips the row if unresolvable. Migration still cleans up historical orphans.

---

## Appendix: Files to Check Before Modifying Subscriptions

1. `app/Modules/Accounting/bootstrap.php` — Listens to `subscription.paid` for journal entries
2. `app/Modules/Accounting/Services/StatementIntegrationService.php` — Statement integration
3. `app/Modules/Payments/Services/BalanceCalculator.php` — Queries subscriptions for member balance
4. `app/Modules/Members/Services/RetroactiveMembershipService.php` — Direct insert into subscriptions
5. `app/Modules/Members/Controllers/ReportController.php` — Subscription status reports
6. `cron/jobs/OverdueFineJob.php` — Cron-based fine application and member drop
7. `cron/jobs/SubscriptionGeneratorJob.php` — Auto-generation in July
8. `app/Modules/Reports/Services/ReportEngine.php` — Overdue subscriptions report
