# ActivitySubscriptions Module — Architecture Map

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

---

## 1. Purpose & Responsibilities

The ActivitySubscriptions module manages **monthly subscription billing for sports activities** (academy enrollments). It handles:
- Monthly subscription generation for all active enrollments
- Pricing management (member vs non-member rates, AM/PM tiers, per-academy/discipline/facility)
- Payment collection (routes payment through PaymentService or InventoryPaymentService)
- Exemption management (waive individual subscriptions with reason)
- Overdue revocation (mark unpaid subscriptions as revoked after grace period)
- Enrollment wizard (enroll players into training groups with available capacity)
- Player search API (used by the enrollment wizard)
- Half-month proration (enrollments after the 15th pay half rate for first month)

It does **NOT** directly manage:
- Player profiles (PlayerAffairs owns players)
- Academy/discipline configuration (Academies and Disciplines modules)
- Payment processing (delegates to Payments/Sales modules)
- Training group scheduling (managed by training_groups table in PlayerAffairs/Academies)

---

## 2. Directory & File Structure

```
app/Modules/ActivitySubscriptions/
├── bootstrap.php                              # Permission registration (5 permissions)
├── Routes.php                                 # 13 routes
├── Controllers/
│   ├── ActivitySubscriptionController.php    # Main CRUD + generate + pay + exempt + pricing
│   ├── EnrollWizardController.php            # Step-by-step enrollment wizard + AJAX APIs
│   └── PlayerSearchController.php            # JSON player search API
├── Models/
│   ├── ActivitySubscription.php              # Monthly subscription records (soft delete)
│   └── ActivityPricing.php                   # Pricing configuration per type/reference
├── Services/
│   ├── ActivitySubscriptionService.php       # Generate subscriptions, pay, revoke, exempt
│   └── ActivityPricingService.php            # Rate lookup and upsert
└── Views/
    ├── index.php                             # Subscription list with status tabs and collection summary
    ├── show.php                              # Subscription detail with pay/exempt actions
    ├── pricing.php                           # Pricing management grid
    └── enroll_wizard.php                     # Interactive enrollment wizard
```

---

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

### 3.1 `activity_subscriptions` Table (0 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| player_id | bigint unsigned | NO | MUL | FK to players |
| enrollment_id | bigint unsigned | YES | MUL | FK to enrollments |
| discipline_id | bigint unsigned | YES | MUL | FK to sport_disciplines |
| subscription_month | varchar(7) | NO | MUL | Format: YYYY-MM |
| player_type | varchar(20) | NO | | 'member' or 'non_member' |
| base_rate | decimal(15,2) | NO | | Full monthly rate, default 0.00 |
| is_half_month | tinyint(1) | NO | | 1 if enrollment started after 15th, default 0 |
| applied_rate | decimal(15,2) | NO | | Actual charged rate (may be half), default 0.00 |
| discount | decimal(15,2) | NO | | base_rate - applied_rate, default 0.00 |
| total_amount | decimal(15,2) | NO | | Final amount due, default 0.00 |
| payment_id | bigint unsigned | YES | MUL | FK to payments (set on payment) |
| status | varchar(20) | NO | MUL | pending/paid/overdue/exempted/revoked, default 'pending' |
| due_date | date | YES | MUL | 7th of subscription month |
| paid_at | timestamp | YES | | When payment was received |
| revoked_at | timestamp | YES | | When subscription was revoked |
| exempted_by | bigint unsigned | YES | | FK to employees (who exempted) |
| exemption_reason | varchar(500) | YES | | Reason for exemption |
| notes | text | YES | | General notes |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| is_archived | tinyint(1) | NO | | Soft delete flag, default 0 |
| archived_at | timestamp | YES | | |
| archived_by | bigint unsigned | YES | | |
| created_by | bigint unsigned | YES | | |
| updated_by | bigint unsigned | YES | | |

### 3.2 `activity_pricing` Table (11 rows as of 2026-06-10)

| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| pricing_type | varchar(30) | NO | MUL | 'academy', 'discipline', or 'facility' |
| reference_id | bigint unsigned | NO | | FK to academies/sport_disciplines/facilities |
| member_rate | decimal(15,2) | NO | | AM rate for members, default 0.00 |
| nonmember_rate | decimal(15,2) | NO | | AM rate for non-members, default 0.00 |
| member_rate_pm | decimal(15,2) | YES | | PM rate for members |
| nonmember_rate_pm | decimal(15,2) | YES | | PM rate for non-members |
| group_type | varchar(20) | YES | MUL | private/small_group/group/team (null = all) |
| service_tier | varchar(20) | YES | | Default: 'standard' |
| effective_from | date | NO | MUL | Start of validity period |
| effective_to | date | YES | | End of validity period (null = indefinite) |
| is_active | tinyint(1) | NO | MUL | Default: 1 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |

### 3.3 Related Tables (owned by other modules)

| Table | Owner Module | Relationship |
|-------|-------------|--------------|
| players | PlayerAffairs | player_id FK |
| enrollments | PlayerAffairs | enrollment_id FK |
| sport_disciplines | Disciplines | discipline_id FK |
| academies | Academies | Referenced via enrollment > academy_levels > academies |
| academy_levels | Academies | Referenced for enrollment level lookups |
| training_groups | PlayerAffairs/Academies | Used by enrollment wizard |
| group_memberships | PlayerAffairs | Created by enrollment wizard |
| payments | Payments | payment_id FK (set on collection) |

---

## 4. Routes

| Method | Path | Action | Middleware | Permission |
|--------|------|--------|------------|------------|
| GET | /activity-subscriptions | index | auth | activity_sub.view |
| GET | /activity-subscriptions/pricing | pricing | auth | activity_sub.manage_pricing |
| POST | /activity-subscriptions/pricing | updatePricing | auth, csrf | activity_sub.manage_pricing |
| POST | /activity-subscriptions/generate | generate | auth, csrf | activity_sub.generate |
| GET | /activity-subscriptions/{id} | show | auth | activity_sub.view |
| POST | /activity-subscriptions/{id}/pay | pay | auth, csrf | activity_sub.collect |
| POST | /activity-subscriptions/{id}/exempt | exempt | auth, csrf | activity_sub.exempt |
| GET | /activity-subscriptions/enroll | enroll wizard | auth | activity_sub.view |
| GET | /activity-subscriptions/enroll/levels | getLevels (JSON) | auth | activity_sub.view |
| GET | /activity-subscriptions/enroll/price | getPrice (JSON) | auth | activity_sub.view |
| GET | /activity-subscriptions/enroll/availability | getAvailability (JSON) | auth | activity_sub.view |
| POST | /activity-subscriptions/enroll | enroll | auth, csrf | academy.enroll |
| GET | /api/players/search | player search (JSON) | auth | player.view |

---

## 5. Core Business Flows

### 5.1 Monthly Subscription Generation
1. Admin selects month (YYYY-MM format) and triggers generation
2. `ActivitySubscriptionService::generateMonthlySubscriptions($month)`:
   - Queries all active enrollments where `start_date <= month-28` and `end_date IS NULL OR >= month-01`
   - For each enrollment, skips if subscription already exists for that enrollment+month
   - Checks if first month and started after 15th (half-month proration)
   - Looks up rate: tries academy pricing first, falls back to discipline pricing
   - Creates subscription with `status='pending'`, `due_date=month-07`
   - Dispatches `activity_sub.generated` event
3. Returns count of generated subscriptions

### 5.2 Payment Collection
1. Admin clicks pay on a subscription
2. Controller looks up player's `member_id`:
   - If member: routes through `PaymentService::processPayment()` (payment_type='activity_subscription')
   - If non-member: routes through `InventoryPaymentService::processGuestPayment()`
3. On success: `ActivitySubscriptionService::paySubscription()` updates status to 'paid'
4. Dispatches `activity_sub.paid` event

### 5.3 Exemption
1. Admin provides reason, controller calls `grantExemption()`
2. Updates status to 'exempted', stores reason and exempted_by
3. Dispatches `activity_sub.exempted` event

### 5.4 Overdue Revocation
1. `revokeOverdue($month)` finds subscriptions where `due_date + 7 days < today`
2. Updates status to 'revoked', sets revoked_at
3. Dispatches `activity_sub.revoked` event

### 5.5 Enrollment Wizard
1. User selects discipline, group type, level, and player
2. AJAX calls fetch levels, prices, and available groups
3. On submit: creates AcademyEnrollment + group_memberships + updates training_group capacity
4. Redirects to player profile

### 5.6 Pricing Management
1. Admin selects pricing_type (academy/discipline/facility) and reference_id
2. Sets member_rate, nonmember_rate, member_rate_pm, nonmember_rate_pm, effective dates
3. `ActivityPricingService::setRate()` deactivates any existing active record and creates new one

---

## 6. Events Dispatched

| Event | Payload | Dispatched From |
|-------|---------|-----------------|
| `activity_sub.generated` | `{subscription_id, player_id, month}` | ActivitySubscriptionService::generateMonthlySubscriptions |
| `activity_sub.paid` | `{subscription_id, player_id, payment_id}` | ActivitySubscriptionService::paySubscription |
| `activity_sub.revoked` | `{subscription_id, player_id, month}` | ActivitySubscriptionService::revokeOverdue |
| `activity_sub.exempted` | `{subscription_id, player_id, exempted_by}` | ActivitySubscriptionService::grantExemption |

---

## 7. Events Consumed (by other modules)

| Event | Listener Module | Action |
|-------|----------------|--------|
| `activity_sub.paid` | PlayerAffairs | Activates player card (backup if not already activated) |

### Events dispatched by OTHER modules that write to activity_subscriptions:

| Event Context | Module | Action |
|---------------|--------|--------|
| Payment confirmed (type='activity_subscription') | PlayerAffairs/EventListeners | Updates activity_subscriptions status to 'paid' |
| Enrollment created | PlayerAffairs/EventListeners | Creates first activity_subscription for the enrollment |
| Enrollment cancelled | PlayerAffairs/EventListeners | Revokes pending subscriptions for that enrollment |

---

## 8. Cross-Module Dependencies

### 8.1 ActivitySubscriptions IMPORTS FROM:

| Module | What it uses |
|--------|-------------|
| Payments | PaymentService::processPayment() — for member payment collection |
| Sales | InventoryPaymentService::processGuestPayment() — for non-member payment |
| Disciplines | SportDiscipline::allActive() — discipline dropdown data |
| Academies | Academy::allActive() — academy name lookups |
| PlayerAffairs | AcademyEnrollment::create() — enrollment wizard creates enrollments |

### 8.2 Other modules that IMPORT FROM ActivitySubscriptions:

| Module | What it imports |
|--------|----------------|
| Accounting | Recognizes 'activity_subscription' payment type for journal entries |
| Cashier | PaymentRequestService allows 'activity_subscription' type without member_id |
| Payments | PaymentService recognizes 'activity_subscription' payment type label |
| ParentPortal | Queries activity_subscriptions table directly for player dashboard |
| Disciplines | SportsDashboardController queries activity_subscriptions for stats |
| SportsDashboard | DashboardMetricsService queries activity_subscriptions for discipline metrics |
| AcademyContracts | SettlementService queries activity_subscriptions for coach settlements |
| PlayerAffairs | EventListeners directly INSERT/UPDATE activity_subscriptions; listens to activity_sub.paid |
| Treasury | Recognizes 'activity_subscription' in payment type filters |

---

## 9. Permissions

| Key | Description |
|-----|-------------|
| activity_sub.view | View subscription list, show detail, enrollment wizard |
| activity_sub.manage_pricing | View and update pricing configuration |
| activity_sub.generate | Generate monthly subscriptions |
| activity_sub.collect | Process payment for a subscription |
| activity_sub.exempt | Grant exemption on a subscription |

**Cross-module permissions used:**
- `academy.enroll` — required for the enrollment POST action
- `player.view` — required for the player search API

---

## 10. Background Processes (Cron)

`revokeOverdue($month)` is available as a service method but its cron integration is not visible in this module. It should be called monthly to auto-revoke overdue subscriptions past the 7-day grace period.

---

## 11. Configuration Dependencies

No system_config keys. Pricing is managed entirely through the `activity_pricing` table.

**Pricing lookup priority:**
1. Academy-specific pricing (`pricing_type='academy'`, `reference_id=academy_id`)
2. Discipline-level fallback (`pricing_type='discipline'`, `reference_id=discipline_id`)

---

## 12. High-Risk Areas

### 12.1 Dual Payment Paths (CRITICAL)
- Member players go through `PaymentService::processPayment()`
- Non-member players go through `InventoryPaymentService::processGuestPayment()`
- If `member_id` lookup fails for a member player, they'd route through guest path
- No rollback if payment succeeds but status update fails

### 12.2 PlayerAffairs EventListeners Overlap
- PlayerAffairs module directly INSERTs and UPDATEs `activity_subscriptions` rows
- This creates two code paths that modify the same table (this module + PlayerAffairs)
- If business logic changes in one, the other may become inconsistent
- PlayerAffairs also dispatches `activity_sub.generated` and `activity_sub.paid` events

### 12.3 Enrollment Wizard Creates Cross-Module Data
- `EnrollWizardController::enroll()` directly creates:
  - `AcademyEnrollment` (PlayerAffairs model)
  - `group_memberships` row (raw SQL insert)
  - Updates `training_groups.current_count` and `is_full`
- Bypasses any enrollment validation or workflow that PlayerAffairs might enforce
- No transaction wrapping — partial creation possible on failure

### 12.4 Rate Calculation Dependencies
- Rate lookup depends on active pricing records with valid date ranges
- If no pricing record exists, rate returns 0.00 — subscription is created with zero amount
- Half-month logic only applies to the enrollment's first month

### 12.5 Module State: No Subscriptions Yet
- `activity_subscriptions` has 0 rows — module is deployed but generation hasn't been run
- 11 pricing records exist (configured but not yet generating subscriptions)

---

## 13. Known Patterns & Gotchas

1. **Due date is always the 7th**: All generated subscriptions have `due_date = month-07`
2. **Grace period is 7 days**: `revokeOverdue()` uses `DATE_ADD(due_date, INTERVAL 7 DAY)`
3. **Half-month proration**: Only applies to enrollment's first month if started after day 15
4. **Pricing deactivation on update**: Setting a new rate deactivates the old one (`is_active=0`)
5. **Group types for pricing**: private (1 player), small_group (2-4), group (5-12), team (13+)
6. **Time tiers**: AM and PM rates available but only used when explicitly passed `timeTier='PM'`
7. **Soft delete on subscriptions**: Uses `is_archived` column for soft deletes
8. **Enrollment wizard capacity check**: Only assigns to groups where `is_full=0`
9. **No subscription deletion**: Subscriptions can be exempted or revoked but not deleted
10. **Collection summary on index**: Aggregates stats for the selected month on every page load
