# Subscriptions Module — Architecture Map

> **Last updated:** 2026-06-10 (updated)
> **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
- 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 only (menu via Members)
├── 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, drop members, expire reinstatements
└── Views/
    ├── index.php                             # Paginated list with filters (year, status, person_type)
    ├── batch-generate.php                    # Admin form to trigger batch generation
    └── member-subscriptions.php              # Per-member subscription history + unpaid summary
```

---

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

### 3.1 `subscriptions` Table (254 rows as of 2026-06-10)

| 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 + dev_fee - discount |
| paid_amount | decimal(15,2) | NO | | 0.00 | Amount paid so far |
| 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 (status):**
- pending: 201
- paid: 39
- overdue: 14

**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.

### 5.2 Subscription Payment

```
1. POST /subscriptions/{id}/pay
2. Calculate remaining: total_amount + fine_amount - paid_amount
3. Call PaymentService::processPayment() with:
   - payment_type = 'annual_subscription'
   - related_entity_type = 'subscriptions'
4. Update subscription: status='paid', paid_at, payment_id
5. Dispatch: subscription.paid event
```

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

```
1. Mark all unpaid subscriptions with paid_amount=0 as status='overdue'
2. For each member with overdue subscriptions:
   a. SubscriptionCalculator::calculateLateFine() computes progressive fines
   b. Update fine_amount on each overdue subscription record
3. Drop members with 5+ consecutive unpaid years:
   - Update member status to 'dropped'
   - Dispatch: member.dropped event
```

### 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:** Both `OverdueFineJob` (cron) and `OverdueFineApplicator` (service class) exist with overlapping logic. The cron job is the active one; the service class provides the same logic callable from controller context.

---

## 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"
```

**Subscription rates (2025/2026):**
| Person Type | Base Amount | Dev Fee | Total |
|-------------|-------------|---------|-------|
| Member | 492.00 | 35.00 | 527.00 |
| Spouse | 492.00 | 0.00 | 492.00 |
| Child | 222.00 | 0.00 | 222.00 |
| Temporary | 222.00 | 0.00 | 222.00 |

---

## 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
- Two implementations exist: `OverdueFineJob` (cron) and `OverdueFineApplicator` (service)
- Both use `SubscriptionCalculator::calculateLateFine()` but the cron job has its own fine update logic
- 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
- The 5-year count uses `COUNT(DISTINCT financial_year)` not consecutive check in cron job

### 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 Dual Cron Logic
- `OverdueFineJob` (active cron) and `OverdueFineApplicator` (service class) have similar but not identical logic
- The cron job marks ALL unpaid as overdue regardless of grace period
- 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

---

## 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
