Commit e139a8a2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(sports-activity): resolve broken queries, fee/threshold drift, and billing gaps

- ActivitySubscriptions: fix generate/calculateRate querying nonexistent
  `enrollments` table; use `academy_enrollments` with correct columns
- SportsDashboard: fix queries against nonexistent `disciplines` table;
  use `sport_disciplines`
- Sports: unify conversion-fee percentage to a single source
  (MembershipRulesService::getAthleticMemberConversionRules), preventing
  the eligibility preview from drifting from what's actually charged
- SportsActivity: align absence-threshold fallback defaults between
  AttendanceRuleService and TrainingAttendanceService via a shared constant
- SportsActivity: auto-bill first month on Registration Wizard completion,
  matching the direct-enrollment path so wizard-registered players aren't
  left uncharged until the monthly batch runs
- ActivitySubscriptions: guard paySubscription() to only transition
  pending/overdue -> paid, making it idempotent against the (currently
  unreachable) payment.completed listener path
- ActivitySubscriptions: dispatch academy.enrollment_created from the
  enroll wizard so PlayerAffairs' auto-billing listener actually fires

Also adds/updates Architecture Maps for Sports, SportsActivity,
SportsDashboard, ActivitySubscriptions and the cross-module Dependency
Graph, per this repo's mandatory architecture-map workflow.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
parent f46e7a77
...@@ -7,6 +7,7 @@ use App\Core\Controller; ...@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\EventBus;
use App\Modules\Disciplines\Models\SportDiscipline; use App\Modules\Disciplines\Models\SportDiscipline;
class EnrollWizardController extends Controller class EnrollWizardController extends Controller
...@@ -198,6 +199,19 @@ class EnrollWizardController extends Controller ...@@ -198,6 +199,19 @@ class EnrollWizardController extends Controller
'is_full' => ((int) $group['current_count'] + 1) >= (int) $group['max_capacity'] ? 1 : 0, 'is_full' => ((int) $group['current_count'] + 1) >= (int) $group['max_capacity'] ? 1 : 0,
], 'id = ?', [(int) $group['id']]); ], 'id = ?', [(int) $group['id']]);
// Trigger auto-billing for the first month. The academy.enrollment_created
// listener in PlayerAffairs/EventListeners.php owns first-month subscription
// generation (including half-month proration); without this dispatch a
// wizard-enrolled player gets no activity_subscriptions row until the monthly
// generate batch is run manually. The listener requires enrollment_id,
// player_id and academy_id to all be non-zero and re-derives everything else
// (level, discipline, pricing) from the database.
EventBus::dispatch('academy.enrollment_created', [
'enrollment_id' => (int) $enrollment->id,
'player_id' => $playerId,
'academy_id' => $academyId,
]);
return $this->redirect('/players/' . $playerId) return $this->redirect('/players/' . $playerId)
->withSuccess('تم تسجيل اللاعب بنجاح في ' . ($group['name_ar'] ?? 'المجموعة')); ->withSuccess('تم تسجيل اللاعب بنجاح في ' . ($group['name_ar'] ?? 'المجموعة'));
} }
......
...@@ -24,17 +24,21 @@ class ActivitySubscriptionService ...@@ -24,17 +24,21 @@ class ActivitySubscriptionService
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$count = 0; $count = 0;
// Get all active enrollments // Get all active enrollments.
// Source table is `academy_enrollments` (there is no `enrollments` table).
// It carries `academy_id` directly, so the academy — and therefore the
// discipline — is resolved without going through `academy_levels`.
// Lifecycle columns are `enrolled_at` (start) and `dropped_at` (end);
// there are no `start_date` / `end_date` columns.
$enrollments = $db->select( $enrollments = $db->select(
"SELECT e.*, p.player_type, a.discipline_id "SELECT e.*, p.player_type, a.discipline_id
FROM `enrollments` e FROM `academy_enrollments` e
JOIN `players` p ON p.id = e.player_id JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id JOIN `academies` a ON a.id = e.academy_id
JOIN `academies` a ON a.id = al.academy_id
WHERE e.`status` = 'active' WHERE e.`status` = 'active'
AND (e.`end_date` IS NULL OR e.`end_date` >= ?) AND (e.`dropped_at` IS NULL OR DATE(e.`dropped_at`) >= ?)
AND e.`start_date` <= ?", AND DATE(e.`enrolled_at`) <= LAST_DAY(?)",
[$month . '-01', $month . '-28'] [$month . '-01', $month . '-01']
); );
foreach ($enrollments as $enrollment) { foreach ($enrollments as $enrollment) {
...@@ -52,9 +56,17 @@ class ActivitySubscriptionService ...@@ -52,9 +56,17 @@ class ActivitySubscriptionService
continue; continue;
} }
// Check if this is the first month and enrollment started after the 15th // Check if this is the first month and enrollment started after the 15th.
$enrollmentDay = (int) date('d', strtotime($enrollment['start_date'] ?? '')); // `enrollment_day` is the authoritative day-of-month for half-month logic
$enrollmentMonth = date('Y-m', strtotime($enrollment['start_date'] ?? '')); // (see the column comment on `academy_enrollments`); fall back to the day
// component of `enrolled_at` when it is not populated.
$enrolledAt = (string) ($enrollment['enrolled_at'] ?? '');
$enrolledAtTs = $enrolledAt !== '' ? strtotime($enrolledAt) : false;
$enrollmentMonth = $enrolledAtTs !== false ? date('Y-m', $enrolledAtTs) : '';
$enrollmentDay = (int) ($enrollment['enrollment_day'] ?? 0);
if ($enrollmentDay <= 0 && $enrolledAtTs !== false) {
$enrollmentDay = (int) date('j', $enrolledAtTs);
}
$isHalfMonth = ($enrollmentMonth === $month && $enrollmentDay > 15); $isHalfMonth = ($enrollmentMonth === $month && $enrollmentDay > 15);
// Calculate rate // Calculate rate
...@@ -103,12 +115,13 @@ class ActivitySubscriptionService ...@@ -103,12 +115,13 @@ class ActivitySubscriptionService
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
// `academy_enrollments.academy_id` already identifies the academy, so the
// discipline is resolved via a single join to `academies`.
$enrollment = $db->selectOne( $enrollment = $db->selectOne(
"SELECT e.*, p.player_type, a.id as academy_id, a.discipline_id "SELECT e.*, p.player_type, a.discipline_id
FROM `enrollments` e FROM `academy_enrollments` e
JOIN `players` p ON p.id = e.player_id JOIN `players` p ON p.id = e.player_id
JOIN `academy_levels` al ON al.id = e.level_id JOIN `academies` a ON a.id = e.academy_id
JOIN `academies` a ON a.id = al.academy_id
WHERE e.`id` = ?", WHERE e.`id` = ?",
[$enrollmentId] [$enrollmentId]
); );
...@@ -136,6 +149,16 @@ class ActivitySubscriptionService ...@@ -136,6 +149,16 @@ class ActivitySubscriptionService
/** /**
* Mark a subscription as paid. * Mark a subscription as paid.
*
* This is the authoritative pending/overdue -> paid transition for
* `activity_subscriptions`. The guard below makes it idempotent: only a
* pending or overdue subscription can transition to paid, so a second
* invocation (or the `payment.completed` listener in
* PlayerAffairs/EventListeners.php, which encodes the same predicate and
* writes the same columns) cannot re-stamp paid_at/payment_id or
* re-dispatch `activity_sub.paid` — which would double-fire card
* activation and audit logging. It also prevents an exempted or revoked
* subscription from being silently flipped to paid.
*/ */
public static function paySubscription(int $subscriptionId, int $paymentId): bool public static function paySubscription(int $subscriptionId, int $paymentId): bool
{ {
...@@ -144,6 +167,10 @@ class ActivitySubscriptionService ...@@ -144,6 +167,10 @@ class ActivitySubscriptionService
return false; return false;
} }
if (!in_array((string) $sub->status, ['pending', 'overdue'], true)) {
return false;
}
$sub->update([ $sub->update([
'status' => 'paid', 'status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'), 'paid_at' => date('Y-m-d H:i:s'),
......
...@@ -6,6 +6,7 @@ namespace App\Modules\Sports\Services; ...@@ -6,6 +6,7 @@ namespace App\Modules\Sports\Services;
use App\Core\App; use App\Core\App;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Pricing\Services\PricingEngine; use App\Modules\Pricing\Services\PricingEngine;
use App\Modules\Members\Services\MembershipRulesService;
final class SportsConversionCalculator final class SportsConversionCalculator
{ {
...@@ -44,8 +45,12 @@ final class SportsConversionCalculator ...@@ -44,8 +45,12 @@ final class SportsConversionCalculator
$priceInfo = PricingEngine::getMembershipPrice((int) $member['branch_id'], $qualCode); $priceInfo = PricingEngine::getMembershipPrice((int) $member['branch_id'], $qualCode);
$newValue = $priceInfo['price'] ?? '0.00'; $newValue = $priceInfo['price'] ?? '0.00';
$feeData = RuleEngine::get('SPORTS_CONVERSION_FEE'); // Single source of truth for the athletic-member conversion fee percentage:
$pct = $feeData['percentage'] ?? '50.00'; // MembershipRulesService::getAthleticMemberConversionRules(). SportsController::store()
// charges the registration fee off this same rule, so the preview shown here can no
// longer drift from what is actually collected.
$conversionRules = MembershipRulesService::getAthleticMemberConversionRules();
$pct = (string) ($conversionRules['conversion_percentage'] ?? '50');
$fee = bcmul($newValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($newValue, bcdiv($pct, '100', 4), 2);
return [ return [
......
...@@ -109,4 +109,10 @@ final class SaConstants ...@@ -109,4 +109,10 @@ final class SaConstants
// Cancelled booking statuses (for exclusion) // Cancelled booking statuses (for exclusion)
const EXCLUDED_STATUSES = ['cancelled', 'no_show']; const EXCLUDED_STATUSES = ['cancelled', 'no_show'];
// Fallback used when system_config key `sa.absence_threshold` is unset.
// Shared by AttendanceRuleService (sa_attendance) and
// TrainingAttendanceService (sa_training_attendance) so both attendance
// sources apply the same threshold when the config is absent.
const DEFAULT_ABSENCE_THRESHOLD = 3;
} }
...@@ -6,10 +6,11 @@ namespace App\Modules\SportsActivity\Services; ...@@ -6,10 +6,11 @@ namespace App\Modules\SportsActivity\Services;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\SportsActivity\SaConstants;
final class AttendanceRuleService final class AttendanceRuleService
{ {
private const DEFAULT_ABSENCE_THRESHOLD = 3; private const DEFAULT_ABSENCE_THRESHOLD = SaConstants::DEFAULT_ABSENCE_THRESHOLD;
public static function checkAbsenceThreshold(int $playerId, int $groupId): ?array public static function checkAbsenceThreshold(int $playerId, int $groupId): ?array
{ {
......
...@@ -531,6 +531,47 @@ final class RegistrationWizardService ...@@ -531,6 +531,47 @@ final class RegistrationWizardService
'is_full' => $newCount >= (int) $group['max_capacity'] ? 1 : 0, 'is_full' => $newCount >= (int) $group['max_capacity'] ? 1 : 0,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]); ], 'id = ?', [$groupId]);
// Bill the first month immediately, mirroring the direct-enrollment path
// (SaEventListenerService::handleEnrollmentPaid). The wizard activates the
// enrollment itself and never passes through that handler, so without this
// the player's first month stays uncharged until the monthly batch runs.
//
// period_start is normalised to the 1st of the registration month so this row
// matches SubscriptionGeneratorService::generateForMonth()'s idempotency key
// (player_id + group_id + period_start = 'YYYY-MM-01') and the batch skips it
// instead of billing the same month a second time.
$subscriptionAmount = (float) ($registration['subscription_amount'] ?? 0);
if ($subscriptionAmount > 0) {
$periodStart = date('Y-m-01');
$periodEnd = date('Y-m-t');
$existingSub = $db->selectOne(
"SELECT id FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start = ?",
[(int) $registration['player_id'], $groupId, $periodStart]
);
if (!$existingSub) {
$amount = number_format($subscriptionAmount, 2, '.', '');
$db->insert('sa_subscriptions', [
'subscription_number' => NumberGeneratorService::subscriptionNumber(),
'player_id' => (int) $registration['player_id'],
'group_id' => $groupId,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'amount' => $amount,
'discount_amount' => '0.00',
'final_amount' => $amount,
'payment_status' => 'paid',
'paid_at' => date('Y-m-d H:i:s'),
'paid_amount' => $amount,
'payment_id' => $paymentId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
}
}
} }
} }
......
...@@ -167,7 +167,7 @@ final class TrainingAttendanceService ...@@ -167,7 +167,7 @@ final class TrainingAttendanceService
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'", "SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[] []
); );
$threshold = (int) ($thresholdRow['config_value'] ?? 5); $threshold = (int) ($thresholdRow['config_value'] ?? SaConstants::DEFAULT_ABSENCE_THRESHOLD);
$monthStart = date('Y-m-01', strtotime($date)); $monthStart = date('Y-m-01', strtotime($date));
$monthEnd = date('Y-m-t', strtotime($date)); $monthEnd = date('Y-m-t', strtotime($date));
......
...@@ -61,7 +61,7 @@ class SportsDashboardController extends Controller ...@@ -61,7 +61,7 @@ class SportsDashboardController extends Controller
$this->authorize('sports_dashboard.view'); $this->authorize('sports_dashboard.view');
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$discipline = $db->selectOne("SELECT * FROM disciplines WHERE id = ? AND is_archived = 0", [(int) $id]); $discipline = $db->selectOne("SELECT * FROM sport_disciplines WHERE id = ? AND is_archived = 0", [(int) $id]);
if (!$discipline) { if (!$discipline) {
return $this->redirect('/sports-dashboard')->withError('الرياضة غير موجودة'); return $this->redirect('/sports-dashboard')->withError('الرياضة غير موجودة');
} }
...@@ -182,7 +182,7 @@ class SportsDashboardController extends Controller ...@@ -182,7 +182,7 @@ class SportsDashboardController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
if ($level === 'sport' && $referenceId > 0) { if ($level === 'sport' && $referenceId > 0) {
$discipline = $db->selectOne("SELECT * FROM disciplines WHERE id = ?", [$referenceId]); $discipline = $db->selectOne("SELECT * FROM sport_disciplines WHERE id = ?", [$referenceId]);
$revenueComparison = DashboardMetricsService::getPreviousPeriodComparison($start, $end, 'revenue', null, $referenceId); $revenueComparison = DashboardMetricsService::getPreviousPeriodComparison($start, $end, 'revenue', null, $referenceId);
$demographics = DashboardMetricsService::getPlayerDemographics($referenceId); $demographics = DashboardMetricsService::getPlayerDemographics($referenceId);
$coaches = DashboardMetricsService::getCoachesForDiscipline($referenceId, $start, $end); $coaches = DashboardMetricsService::getCoachesForDiscipline($referenceId, $start, $end);
......
...@@ -72,7 +72,7 @@ final class DashboardMetricsService ...@@ -72,7 +72,7 @@ final class DashboardMetricsService
SELECT d.id, d.name_ar, d.name_en, SELECT d.id, d.name_ar, d.name_en,
COALESCE(SUM(p.amount), 0) AS revenue, COALESCE(SUM(p.amount), 0) AS revenue,
COUNT(DISTINCT asub.player_id) AS player_count COUNT(DISTINCT asub.player_id) AS player_count
FROM disciplines d FROM sport_disciplines d
LEFT JOIN activity_subscriptions asub ON asub.discipline_id = d.id AND asub.status = 'active' LEFT JOIN activity_subscriptions asub ON asub.discipline_id = d.id AND asub.status = 'active'
LEFT JOIN payments p ON p.related_entity_type = 'discipline' AND p.related_entity_id = d.id LEFT JOIN payments p ON p.related_entity_type = 'discipline' AND p.related_entity_id = d.id
AND p.payment_date BETWEEN ? AND ? AND p.is_voided = 0 AND p.payment_date BETWEEN ? AND ? AND p.is_voided = 0
......
# ActivitySubscriptions Module — Architecture Map
## Module Purpose
Manages monthly recurring subscription billing for **academy-based sports enrollment** — member/non-member
players enrolled in academies, billed monthly with half-month proration — plus a lightweight "enroll
wizard" for assigning a player into a `training_groups` group.
This is a **distinct, earlier-generation parallel system** to `SportsActivity` (see
`docs/architecture-maps/SportsActivity.md`). It builds on the **core `players` table** and the
`academies`/`academy_levels`/`academy_enrollments`/`training_groups`/`group_memberships` schema
(migrations `Phase_18-45`), not `SportsActivity`'s `sa_players`/`sa_*` schema (`Phase_70+`). **Both systems
are simultaneously live in production and cross-feed each other's financial data** — this is not a clean
legacy-replaced-by-new situation. See the Dependency Graph for the full picture.
## System Responsibilities
1. Generate monthly subscription rows for academy enrollments (`activity_subscriptions`).
2. Collect/exempt subscription payments.
3. Manage activity pricing rates by academy/discipline/facility (`activity_pricing`).
4. Lightweight enroll wizard: discipline → level → group availability → player search → enroll into a
`training_groups` group.
## File Structure
```
app/Modules/ActivitySubscriptions/
├── bootstrap.php — permissions only, NO menu entry registered
├── Routes.php
├── Controllers/
│ ├── ActivitySubscriptionController.php — index, pricing, updatePricing, generate, show, pay, exempt
│ ├── EnrollWizardController.php — index, getLevels, getPrice, getAvailability, enroll
│ └── PlayerSearchController.php — search (used by enroll wizard)
├── Models/
│ ├── ActivitySubscription.php — table: activity_subscriptions
│ └── ActivityPricing.php — table: activity_pricing
├── Services/
│ └── ActivitySubscriptionService.php — generateMonthlySubscriptions, calculateRate, paySubscription,
│ revokeOverdue, grantExemption
└── Views/
├── index.php — list + tabs by status/month, on-page workflow guide
├── show.php — detail card, pay/exempt actions
├── pricing.php — rate matrix editor by academy/discipline/facility
└── enroll_wizard.php — multi-step: discipline → level → group-type/availability → price → player → enroll
```
**No sidebar menu entry** — the module is only reachable via a manual link in
`app/Modules/Disciplines/Views/sports_dashboard.php` (`/activity-subscriptions`), itself part of a
different sports-adjacent dashboard.
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/activity-subscriptions` | `ActivitySubscriptionController@index` | `activity_sub.view` |
| GET | `/activity-subscriptions/pricing` | `@pricing` | `activity_sub.manage_pricing` |
| POST | `/activity-subscriptions/pricing` | `@updatePricing` | `activity_sub.manage_pricing` |
| POST | `/activity-subscriptions/generate` | `@generate` | `activity_sub.generate`**broken, see Risk Areas** |
| GET | `/activity-subscriptions/{id}` | `@show` | `activity_sub.view` |
| POST | `/activity-subscriptions/{id}/pay` | `@pay` | `activity_sub.collect` |
| POST | `/activity-subscriptions/{id}/exempt` | `@exempt` | `activity_sub.exempt` |
| GET | `/activity-subscriptions/enroll` | `EnrollWizardController@index` | `activity_sub.view` |
| GET | `/activity-subscriptions/enroll/levels` | `@getLevels` | `activity_sub.view` |
| GET | `/activity-subscriptions/enroll/price` | `@getPrice` | `activity_sub.view` |
| GET | `/activity-subscriptions/enroll/availability` | `@getAvailability` | `activity_sub.view` |
| POST | `/activity-subscriptions/enroll` | `@enroll` | `academy.enroll` (foreign permission, not registered by this module) |
| GET | `/api/players/search` | `PlayerSearchController@search` | `player.view` (foreign permission) |
## Permissions
`activity_sub.view`, `activity_sub.manage_pricing`, `activity_sub.generate`, `activity_sub.collect`,
`activity_sub.exempt` (registered in `bootstrap.php`, group `activity_subscriptions`).
## Database Schema (migration-derived — NOT live-DB verified)
### `activity_subscriptions` (`Phase_22_001_create_activity_subscriptions_table.php`)
| Column | Notes |
|---|---|
| id | PK |
| player_id | FK → `players`, CASCADE |
| enrollment_id | FK → `academy_enrollments`, SET NULL |
| discipline_id | FK → `sport_disciplines`, SET NULL |
| subscription_month | VARCHAR(7) `'YYYY-MM'` |
| player_type, base_rate, is_half_month, applied_rate, discount, total_amount | billing fields |
| payment_id | FK → `payments`, SET NULL |
| status | `pending`/`paid`/`overdue`/`exempted`/`revoked` (Model uses `exempted`; migration comment says `exempt` — verify actual enum on live DB) |
| due_date, paid_at, revoked_at, exempted_by, exemption_reason, notes | |
| soft-delete + audit columns | is_archived/archived_at/archived_by, created_by |
### `activity_pricing` (`Phase_22_002...php`, altered by `Phase_45_004_alter_activity_pricing_add_group_type.php`)
id, pricing_type (`academy`/`discipline`/`facility`), reference_id, member_rate, nonmember_rate,
member_rate_pm, nonmember_rate_pm, effective_from/to, is_active, group_type (added later), service_tier
(added later, default `standard`), created_by.
Model `ActivitySubscription` ($table, softDelete=true, autoTrackAuthor=true); `ActivityPricing`
(softDelete=false).
## Business Rules
### `ActivitySubscriptionService::generateMonthlySubscriptions()` / `calculateRate()`
**FIXED** (was: queried a table literally named `enrollments`, which no migration creates — the endpoint
threw "unknown table" on every invocation).
Both methods now read from **`academy_enrollments`**. Key schema facts that drove the rewrite
(`Phase_19_003`, no later ALTERs):
- `academy_enrollments` carries **`academy_id` directly**, so the academy — and therefore
`academies.discipline_id` — is resolved with a single join to `academies`. The old
`JOIN academy_levels al ON al.id = e.level_id → JOIN academies a ON a.id = al.academy_id` detour is gone
(and would have dropped rows where `level_id IS NULL`, which is permitted).
- There are **no `start_date` / `end_date` columns**. The lifecycle columns are `enrolled_at` (start) and
`dropped_at` (end). The month filter is now
`DATE(enrolled_at) <= LAST_DAY('YYYY-MM-01')` + `(dropped_at IS NULL OR DATE(dropped_at) >= 'YYYY-MM-01')`.
- Half-month proration now reads the dedicated **`enrollment_day`** column (its migration comment states it
exists precisely "for half-month logic"), falling back to the day component of `enrolled_at` when unset.
This matches what `PlayerAffairs\EventListeners.php` already does.
Proration, rate resolution (`ActivityPricing::getEffectiveRate`, academy → discipline fallback), due-date
(7th of month) and the `activity_sub.generated` dispatch are unchanged.
### `EnrollWizardController::enroll()`
Assumes the player already exists in `players`; picks discipline → `academy_levels``training_groups`
availability → creates a `PlayerAffairs\Models\AcademyEnrollment` (table `academy_enrollments`) +
`group_memberships` row + increments `training_groups.current_count`.
**FIXED**: it now dispatches `academy.enrollment_created` with
`['enrollment_id', 'player_id', 'academy_id']` after the rows are committed. That is exactly the payload
`PlayerAffairs\EventListeners.php` requires (it early-returns unless all three are non-zero, and re-derives
level/discipline/pricing from the DB itself). Previously the event was **dispatched nowhere in the
codebase**, so the auto-billing listener was unreachable and wizard-enrolled players received no
`activity_subscriptions` row at all until someone manually ran the (also-broken) `generate` batch.
Note the wizard still does not create the subscription row itself — the listener owns first-month
generation, including half-month proration. That keeps a single owner for the rule.
### `ActivitySubscriptionController::pay()` — the "double-dispatch" was a misdiagnosis
**Corrected finding.** There is **no** double dispatch. Traced end-to-end:
1. `PaymentService::processPayment()` inserts into `payments`/`receipts` and dispatches `payment.completed`
with a payload of **only** `payment_id, receipt_id, receipt_number, member_id, payment_type, amount,
method, treasury_id`. It never writes to `activity_subscriptions` and never forwards
`related_entity_type`/`related_entity_id`.
2. `PlayerAffairs\EventListeners.php`'s `activity_subscription` branch looks the row up with
`WHERE payment_id = ? AND status IN ('pending','overdue')`. But `activity_subscriptions.payment_id` is
still **NULL** at that moment — nothing has stamped it yet. **The listener matches nothing and is a
no-op for this flow.**
3. `ActivitySubscriptionService::paySubscription()` — called explicitly by the controller — is therefore the
**only** code that sets `status='paid'`, `paid_at` and `payment_id`, and the only dispatcher of
`activity_sub.paid`. Card activation happens once, via the separate `activity_sub.paid` listener.
The guest branch (`Sales\Services\InventoryPaymentService::processGuestPayment()`) is a thin wrapper around
`processPayment()`, so it behaves identically.
**Removing the explicit `paySubscription()` call would have silently broken payment collection entirely**
(payments recorded, subscriptions never marked paid). Instead, `paySubscription()` was hardened with a state
guard: it now no-ops unless the current status is `pending` or `overdue`. This makes the authoritative
transition idempotent against any future double-invocation and stops an `exempted`/`revoked` subscription
from being flipped to `paid`.
The listener's `activity_subscription` branch is effectively **dead code** — it can only ever fire if some
future flow stamps `payment_id` *before* the payment completes (a request-then-collect pattern). Left in
place deliberately; flagged here rather than removed.
## EventBus
| Event | Dispatched by | Listened by |
|---|---|---|
| `activity_sub.generated` | `ActivitySubscriptionService::generateMonthlySubscriptions()` **and** `PlayerAffairs\EventListeners.php`'s `academy.enrollment_created` handler — two independent code paths | none found |
| `activity_sub.paid` | `ActivitySubscriptionService::paySubscription()` only (the `payment.completed` handler's copy is unreachable — see `pay()` above) | `PlayerAffairs\EventListeners.php` (card activation) |
| `activity_sub.revoked` | `ActivitySubscriptionService::revokeOverdue()` | none found |
| `activity_sub.exempted` | `ActivitySubscriptionService::grantExemption()` | none found |
| `academy.enrollment_created` | `EnrollWizardController::enroll()` (added — previously dispatched nowhere) | `PlayerAffairs\EventListeners.php` — duplicates this module's rate/proration logic, writes directly to `activity_subscriptions` |
| `academy.enrollment_dropped` | not dispatched anywhere found in this pass | `PlayerAffairs\EventListeners.php` — cancels pending `activity_subscriptions` rows, sets `status='revoked'` |
## Cross-Module Integration
- **`PlayerAffairs\EventListeners.php`** is effectively a **second, independent implementation** of this
module's billing logic — reads/writes `activity_subscriptions` directly from event handlers rather than
calling `ActivitySubscriptionService`. Treat it as a co-owner of the `activity_subscriptions` table.
- **`AcademyContracts\Services\SettlementService::calculateSettlement()`** sums `activity_subscriptions`
revenue (joined to `academy_enrollments`) **and** `SportsActivity`'s `sa_group_players`/`sa_groups`/
`sa_coaches` revenue in one settlement figure for the same academy contract — confirms both systems are
simultaneously load-bearing for financial settlements.
- **`Payments\Services\PaymentService`** registers `activity_subscription => 'اشتراك نشاط'` as a known
payment-type label; dispatches `payment.completed` on processing.
- **`Accounting\AccountCodes`**: `ACTIVITY_SUBSCRIPTION = '410516'` GL account, mapped from payment_type
`activity_subscription` for journal routing.
- **`Cashier\Services\PaymentRequestService`**: `activity_subscription` is in the whitelist of payment
types allowed without a `member_id` (guest path), alongside SportsActivity's own payment types.
- **`Treasury\Services\TreasuryService`** and **`Dashboard\Config\widgets.php`**: both bundle
`activity_subscription` together with `SportsActivity`'s payment types (`hourly_booking`,
`sports_registration`, `sa_form_fee`, `sa_subscription`, etc.) as one logical "sports/activity" bucket for
pending-collection reporting.
- **`SportsDashboard\Services\DashboardMetricsService`** and **`Disciplines\Controllers\SportsDashboardController`** query `activity_subscriptions` directly (joined/filtered by `discipline_id`) for
sports-dashboard metrics — this is the only in-app link to `/activity-subscriptions`.
- **`ParentPortal\Services\ParentService`** — shows a child's last 6 activity subscriptions.
- **`PlayerAffairs\Controllers\PlayerController`** and **`Notifications\Services\CronNotificationService`**
join `activity_subscriptions` for player profile display and overdue/due-date reminders.
- **No references found** from `Members`, `Pricing`, `HR`, or the generic `Reports` module — Members is
decoupled (link only via `players.member_id`); coach/instructor entities have no HR employee linkage;
sports/academy reporting lives inside `SportsActivity\Controllers\SaReportController` and
`Disciplines`/`SportsDashboard`, not the generic Reports module.
## Wizard Comparison — `EnrollWizardController` vs `SportsActivity\RegistrationWizardController`
Both are "sign a player up" flows with overlapping intent but disjoint data models — not simply redundant
code on the same tables:
- `EnrollWizardController` assumes the player already exists in `players`; creates `academy_enrollments` +
`group_memberships`; no medical/photo/NID capture.
- `SportsActivity\RegistrationWizardController` does full player registration/lookup against `sa_players`
(NID parsing, guardian info, membership validation) and writes to `sa_registrations`/`sa_players`/
`sa_groups` — a much richer onboarding flow.
## Risk Areas / Technical Debt
1. ~~**`generate` action is broken**~~ — **RESOLVED**: repointed at `academy_enrollments` with the correct
join/column names (see Business Rules above). Not yet exercised against the live DB.
2. **No sidebar menu entry** — module is nearly orphaned from normal navigation, reachable only via a link
buried in a different module's dashboard view.
3. **Duplicate billing logic**: `ActivitySubscriptionService` and `PlayerAffairs\EventListeners.php`
independently implement rate calculation, proration, and subscription generation against the same table —
a rate/proration rule change must be applied in both places.
4. ~~**Double-dispatch on payment**~~ — **withdrawn, this was a misdiagnosis**. Only one path actually
works; the event-driven one is unreachable because `payment_id` is unset when the event fires. See
`pay()` under Business Rules. `paySubscription()` was given a `pending|overdue` state guard so the
single authoritative transition is now idempotent. The listener's `activity_subscription` branch is
**dead code** and should be either wired up properly or deleted — do not "fix" it by deleting the
controller's explicit call, which is what actually collects the money.
5. ~~**Silent auto-billing gap**~~ — **RESOLVED**: `EnrollWizardController::enroll()` now dispatches
`academy.enrollment_created`. Prior to this the event had **no dispatcher anywhere in the codebase**, so
the listener was pure dead weight.
6. **Major consolidation risk** (see Dependency Graph): `ActivitySubscriptions`/`Academies`/`PlayerAffairs`
and `SportsActivity` are two parallel, concurrently-live systems covering essentially the same domain
(academy enrollment + monthly subscription billing) on incompatible data models. Any business-rule change
to subscription billing, pricing, or enrollment must be evaluated against **both** systems until a
consolidation decision is made.
7. **Database Truth Rule caveat**: schema details above are migration-derived, not live-DB verified — the
exact `status` enum values and the `sa_discount_rules`-equivalent `activity_pricing` late-added columns
should be confirmed against the live DB before relying on them for implementation.
...@@ -403,6 +403,241 @@ Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'` ...@@ -403,6 +403,241 @@ Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'`
--- ---
## Sports Activities Cycle (Sports, SportsActivity, SportsDashboard, ActivitySubscriptions)
**⚠ This is the single riskiest area of the codebase for hidden duplication.** Four modules span
essentially two, largely-incompatible generations of the same "academy/activity enrollment + billing"
domain, plus multiple duplicate sub-systems within the newer generation itself. See individual maps:
`docs/architecture-maps/Sports.md`, `SportsActivity.md`, `SportsDashboard.md`, `ActivitySubscriptions.md`.
### The two generations
| | Older generation (`Phase_18-45`) | Newer generation (`Phase_70+`) |
|---|---|---|
| Player table | `players` | `sa_players` |
| Subscription table | `activity_subscriptions` | `sa_subscriptions` |
| Enrollment table | `academy_enrollments` + `group_memberships` | `sa_group_players` |
| Group table | `training_groups` | `sa_groups` |
| Academy/contract | `Academies` + `AcademyContracts` modules | `sa_academies` + `sa_academy_contracts` (inside `SportsActivity`) |
| Pool scheduling | `PoolManagement` (`pool_*` tables) → `FacilityGrids` (mid-gen) | `SportsActivity` Mirror/PoolGrid/PoolReservation/PoolTicket (`sa_pool_*`) |
| Waitlist | `TrainingGroups` module's `WaitingListController` | `SportsActivity`'s `WaitlistController`/`sa_waitlist` |
| Modules | `ActivitySubscriptions`, `Academies`, `PlayerAffairs`, `Disciplines`/`SportsDashboard`, `TrainingGroups`, `PoolManagement` | `SportsActivity` (monolithic), `FacilityGrids` (transitional) |
**Both generations are simultaneously live** — not a clean legacy-replaced-by-new split. Confirmed by
`AcademyContracts\Services\SettlementService::calculateSettlement()`, which sums revenue from **both**
`activity_subscriptions`/`academy_enrollments` (old) **and** `sa_group_players`/`sa_groups`/`sa_coaches`
(new) in the same settlement figure for one academy contract. `Sports` (the athlete-membership-conversion
module) is unrelated to either generation — it's a Members-module satellite, not an activity/booking system.
### Module → Module Dependencies
| Module | Depends On | How |
|---|---|---|
| Sports | Members (MembershipRulesService), Cashier (PaymentRequestService), Rules (RuleEngine), Pricing (PricingEngine) | Registration fee calc, conversion eligibility/fee preview |
| SportsDashboard | Shared (PdfExportService) | Export; otherwise a pure read/leaf module over legacy (non-`sa_`) tables |
| SportsActivity | Cashier (PaymentRequestService), Payments (PaymentService — two parallel payment patterns, see SportsActivity.md), Members (MembershipValidationService), Notifications (SmsNotificationService), Carnets (QRCodeGenerator), Shared (PdfExportService) | Payment collection, medical validation, SMS alerts, card QR codes, PDF reports |
| ActivitySubscriptions | Payments (PaymentService), core `players` table, `academies`/`academy_levels`/`academy_enrollments`/`training_groups` | Billing, enrollment (via foreign `academy.enroll`/`player.view` permissions) |
| AcademyContracts | ActivitySubscriptions (`activity_subscriptions`/`academy_enrollments`) AND SportsActivity (`sa_group_players`/`sa_groups`/`sa_coaches`) | Settlement revenue calculation spans both generations |
| PlayerAffairs | ActivitySubscriptions (`activity_subscriptions` table, direct read/write) | `EventListeners.php` duplicates `ActivitySubscriptionService`'s billing/proration logic |
### Database Dependencies (Shared Entities)
- **`activity_subscriptions`** — written by `ActivitySubscriptionService` (old-gen module) AND
`PlayerAffairs\EventListeners.php` (duplicate logic, different code path) AND read by
`SportsDashboard\DashboardMetricsService`, `Disciplines\SportsDashboardController`,
`AcademyContracts\SettlementService`, `ParentPortal\ParentService`, `Notifications\CronNotificationService`.
- **`sa_*` tables** (new-gen) — owned entirely by `SportsActivity`; read externally only by
`AcademyContracts\SettlementService` for settlement totals.
- **Payment-type bucketing**`activity_subscription` (old-gen) and SportsActivity's payment types
(`sports_registration`, `hourly_booking`, `sa_form_fee`, `sa_subscription`, `sports_subscription`,
`sa_registration_fee`, `sa_game_ticket`, `sa_pool_ticket`, `pool_reservation`) are treated as siblings in
the same guest-payment whitelist (`Cashier\PaymentRequestService`), the same pending-collection filters
(`Treasury\TreasuryService`, `Dashboard\Config\widgets.php`), and the same GL routing category
(`Accounting\AccountCodes`) — i.e. accounting/treasury reporting already treats both generations as one
logical "sports/activity revenue" bucket even though the underlying data models are disjoint.
- **`sports_members`** (Sports module) — read by `Archive\ArchiveService`, `Members\Views\show.php`,
`Audit\AuditService`. Payment-type string `sports_membership_fee` (membership conversion, NOT an
activity/subscription payment) is a recognized membership-activation type across `Cashier`, `Treasury`,
`Dashboard`, `Members\BillingService`/`MembershipPaymentGuard` — do not confuse with `sports_subscription`/
`activity_subscription`.
### Event Dependencies
| Event | Dispatched By | Listened By |
|---|---|---|
| `payment_request.completed` | Cashier | `SportsActivity\SaEventListenerService` (per-`related_entity_type` routing) |
| `payment.voided` | Payments | `SportsActivity\SaEventListenerService` (symmetric reversal) |
| `payment.completed` (type=`activity_subscription`) | Payments | `PlayerAffairs\EventListeners.php` (marks `activity_subscriptions` paid, activates card) |
| `academy.enrollment_created` | not located (likely `Academies\Services\EnrollmentService`) | `PlayerAffairs\EventListeners.php` (auto-generates `activity_subscriptions` — duplicates `ActivitySubscriptionService`) |
| `academy.enrollment_dropped` | not located | `PlayerAffairs\EventListeners.php` (revokes pending subscriptions) |
| `activity_sub.paid` | `ActivitySubscriptionService` AND `PlayerAffairs\EventListeners.php` (double-dispatch on `pay()`, see ActivitySubscriptions.md) | `PlayerAffairs\EventListeners.php` (backup card activation) |
| `sa.waitlist.offer`, `sa.card.issued/renewed`, `sa.player.paused/resumed/transferred`, `sa.medical_grace.expired`, `sa.registration.completed`, `sa.gate.access_denied` | SportsActivity services | SMS listeners / Logger in `SportsActivity\bootstrap.php` |
| `sports.registered` | `Sports\SportsController::store()` | **none — dead event** |
| `sa.subscription.overdue` | listened for, **no dispatcher found** | SMS listener (likely dead) |
### Permission Dependencies
- `sports.*` — Sports module (membership conversion, unrelated to activities)
- `sports_dashboard.*` — SportsDashboard
- `sa.*` — SportsActivity (60+ keys: `sa.dashboard`, `sa.discipline.*`, `sa.facility.*`, `sa.coach.*`,
`sa.player.*`, `sa.program.*`, `sa.group.*`, `sa.schedule.*`, `sa.booking.*`, `sa.pricing.*`,
`sa.subscription.*`, `sa.attendance.*`, `sa.registration.*`, `sa.card.*`, `sa.enrollment.manage`,
`sa.coach_assessment.*`, `sa.report.*`, `sa.locker*`, `sa.waitlist.manage`, `sa.gate.*`,
`sa.pool_grid.*`, `sa.pool_ticket.*`, `sa.swimming.*`, `sa.academy.*`, `sa.contract.*`,
`sa.institution.*`, `sa.makeup.*`, `sa.mirror.view`, `sa.game.*`)
- `activity_sub.*` — ActivitySubscriptions (view/manage_pricing/generate/collect/exempt); its own routes
additionally depend on **foreign** permissions `academy.enroll` and `player.view`
- `academy.*` — Academies module (old-gen, parallel to `sa.academy.*`)
- `academy_contract.*` — AcademyContracts module (old-gen, parallel to `sa.contract.*`)
- `training_group.*` — TrainingGroups module (old-gen, parallel to `sa.group.*`/`sa.waitlist.manage`)
- `pool.*` — PoolManagement (oldest-gen); `facility_grid.*` — FacilityGrids (mid-gen); both parallel to
SportsActivity's pool routes.
### Cascading Impact Analysis
- **If `activity_subscriptions` billing rules change** (proration, discount %, rate lookup): must be
updated in BOTH `ActivitySubscriptionService::generateMonthlySubscriptions()`/`calculateRate()` AND
`PlayerAffairs\EventListeners.php`'s `academy.enrollment_created` handler — they independently
reimplement the same logic. Missing one causes silent billing drift between enrollment-triggered and
manually-generated subscriptions.
- **If `sa_subscriptions` payment-completion logic changes**: must be updated in BOTH
`SaEventListenerService::handleSubscriptionPaid` (Cashier request-queue path) AND
`SaPaymentService::paySubscription` (direct Payments-module path) — see SportsActivity.md.
- **If academy/contract commission or settlement rules change**: `AcademyContracts\SettlementService`
reads from both generations — a fix scoped to only `sa_academy_contracts` or only
`academy_enrollments`/`activity_subscriptions` will silently miscalculate settlements for academies with
data in the other system.
- **If pool scheduling/booking rules change**: verify which of the three live pool systems
(`PoolManagement`, `FacilityGrids`, `SportsActivity`'s Mirror/PoolGrid) is actually in front-line use
before assuming a fix in one covers real operations.
- **If `sa.absence_threshold` config changes**: affects `AttendanceRuleService` and
`TrainingAttendanceService` differently — they already disagree on the default value and event name: audit
both before relying on the config key alone.
- **If a discipline/program/group is renamed or its table structure changes**: check both `sa_disciplines`/
`sa_programs`/`sa_groups` (new-gen) AND `sport_disciplines`/`academies`/`academy_levels`/`training_groups`
(old-gen) — they are not the same rows and do not sync.
---
## Sports Activity Cluster (Sports, SportsActivity, SportsDashboard, ActivitySubscriptions,
## plus the parallel legacy systems they overlap with)
**⚠ Major architectural fact**: there are TWO parallel, concurrently-live systems covering the same
domain (academy/sports enrollment + monthly subscription billing), on incompatible data models, both
feeding the same financial reporting/settlement pipeline:
- **Legacy generation** (migrations `Phase_18-45`, unprefixed tables): `ActivitySubscriptions`,
`Academies`, `AcademyContracts`, `PlayerAffairs`, `TrainingGroups`, `PoolManagement` — tables `players`,
`academies`, `academy_levels`, `academy_enrollments`, `training_groups`, `group_memberships`,
`activity_subscriptions`, `activity_pricing`, `pool_schedules`, `pool_bookings`, `pool_configurations`.
- **Current generation** (migrations `Phase_70+`, `sa_`-prefixed tables): `SportsActivity` (the actively
developed module — 55+ controllers, full sidebar menu) — tables `sa_players`, `sa_groups`,
`sa_group_players`, `sa_subscriptions`, `sa_bookings`, `sa_academies`, `sa_academy_contracts`,
`sa_pool_reservations`, `sa_pool_zone_bookings`, `sa_pool_tickets`, etc. Also `FacilityGrids` (a
mid-generation pool-scheduling module sitting between the two).
- **`Sports`** module (`sports_members` table) is unrelated to either — it's a Members-module satellite for
the "athletic member" special membership category, not an activity-booking system.
- **`SportsDashboard`** queries the legacy, unprefixed tables (`sport_disciplines`, `facilities`, `players`,
`reservations`, `activity_subscriptions`) — it was never migrated to `sa_*` and may be stale (see
`docs/architecture-maps/SportsDashboard.md`). Its references to a nonexistent `disciplines` table have
been repointed at `sport_disciplines`.
See `docs/architecture-maps/Sports.md`, `SportsActivity.md`, `SportsDashboard.md`,
`ActivitySubscriptions.md` for full per-module detail. This section covers only the cross-module
connective tissue.
### Module → Module Dependencies
| Module | Depends On | How |
|---|---|---|
| SportsActivity | Cashier (PaymentRequestService) | Request-then-collect payments for registration/enrollment/card-renewal/pool-tickets/games |
| SportsActivity | Payments (PaymentService) | Direct/synchronous payments for subscriptions/bookings via `SaPaymentService` — a SECOND, parallel payment pattern to Cashier (see Risk below) |
| SportsActivity | Notifications (SmsNotificationService) | Card expiry, subscription overdue, transfer, absence-threshold, waitlist-offer, gate-denial SMS |
| SportsActivity | Carnets (QRCodeGenerator) | SA player card QR codes |
| SportsActivity | Members (MembershipValidationService) | Registration wizard member lookup |
| ActivitySubscriptions | PlayerAffairs (EventListeners.php) | **Duplicate** billing logic — both read/write `activity_subscriptions` independently |
| ActivitySubscriptions | Academies / TrainingGroups | `academy_enrollments`, `training_groups`, `group_memberships` |
| AcademyContracts (SettlementService) | ActivitySubscriptions AND SportsActivity | Sums revenue from BOTH `activity_subscriptions`+`academy_enrollments` (legacy) AND `sa_group_players`+`sa_groups`+`sa_coaches` (current) in ONE settlement figure |
| Sports | Members (MembershipRulesService) | Athletic-member conversion fee % — now the **single** source for both the registration charge and the conversion-fee preview |
| Sports | Cashier (PaymentRequestService) | Sports membership fee collection |
| Sports | Rules (RuleEngine), Pricing (PricingEngine) | Conversion eligibility (min years only; the `SPORTS_CONVERSION_FEE` rule is now unreferenced by code) |
| SportsDashboard | (queries directly, no service dependency) | Legacy `sport_disciplines`/`facilities`/`reservations`/`activity_subscriptions` tables |
### Database Dependencies (Shared / Overlapping Tables)
| Table(s) | Written by | Also read by |
|---|---|---|
| `activity_subscriptions` | `ActivitySubscriptions\ActivitySubscriptionService` **and** `PlayerAffairs\EventListeners.php` (independently, both writers) | `AcademyContracts\SettlementService`, `SportsDashboard`, `ParentPortal\ParentService`, `PlayerAffairs\PlayerController`, `Notifications\CronNotificationService` |
| `sa_subscriptions`, `sa_group_players`, `sa_groups`, `sa_coaches` | `SportsActivity` services | `AcademyContracts\SettlementService` (same settlement calc as above) |
| `sports_members` | `Sports\SportsMember` model | `Archive\ArchiveService`, `Members\Views\show.php`, `Audit\AuditService` |
| Payment-type strings bundled as one "sports/activity" bucket: `activity_subscription`, `hourly_booking`, `sports_registration`, `sa_form_fee`, `sa_subscription`, `sports_subscription`, `sa_registration_fee`, `sa_game_ticket`, `sa_pool_ticket`, `pool_reservation`, `sports_membership_fee` | Cashier / Payments | `Treasury\TreasuryService`, `Dashboard\Config\widgets.php`, `Accounting\AccountCodes` (GL routing), `Accounting\AccountingIntegrationService` |
### Event Dependencies
| Event | Dispatched by | Listened by |
|---|---|---|
| `payment_request.completed` | Cashier | `SportsActivity\SaEventListenerService` (dispatches to per-entity handlers by `related_entity_type`) |
| `payment.voided` | Payments | `SportsActivity\SaEventListenerService` (symmetric reversal) |
| `payment.completed` | Payments | `PlayerAffairs\EventListeners.php` — its `activity_subscription` branch is **dead code**: it matches on `activity_subscriptions.payment_id`, which is still NULL when the event fires. `ActivitySubscriptions\Services\ActivitySubscriptionService::paySubscription()` is the real (and only) pending→paid transition. |
| `academy.enrollment_created` | `ActivitySubscriptions\EnrollWizardController::enroll()`**added**; previously dispatched by nothing anywhere, leaving the listener unreachable | `PlayerAffairs\EventListeners.php` (duplicates `ActivitySubscriptionService`'s rate/proration logic) |
| `academy.enrollment_dropped` | source not confirmed in this pass | `PlayerAffairs\EventListeners.php` (revokes pending subscriptions) |
| `sa.waitlist.offer`, `sa.card.expiry_reminder`, `sa.subscription.overdue`, `sa.player.transferred`, `sa.player.absence_threshold`, `sa.gate.access_denied` | SportsActivity services | SportsActivity bootstrap (SMS/log) |
| `sports.registered` | `Sports\SportsController::store()` | **none — dead event** |
### Permission Dependencies
- `sports.*` (Sports), `sports_dashboard.*` (SportsDashboard), `sa.*` (SportsActivity, 60+ keys),
`activity_sub.*` (ActivitySubscriptions), `academy.*` (Academies), `academy_contract.*`
(AcademyContracts), `training_group.*` (TrainingGroups), `pool.*` (PoolManagement), `facility_grid.*`
(FacilityGrids) — nine independent permission namespaces across what is functionally two overlapping
domains. `ActivitySubscriptions\EnrollWizardController::enroll()` gates on the foreign `academy.enroll`
permission rather than its own `activity_sub.*` namespace.
### Cascading Impact Analysis
**If `activity_subscriptions` billing/proration rules change:**
- Must update `ActivitySubscriptions\Services\ActivitySubscriptionService` AND
`PlayerAffairs\EventListeners.php` (independent duplicate implementations) to stay in sync.
- `AcademyContracts\SettlementService` revenue figures shift.
- `SportsDashboard` metrics shift (`getDisciplineBreakdown()` now resolves `sport_disciplines` correctly,
but its `activity_subscriptions.status = 'active'` predicate still matches no rows — see Risk Areas in
`SportsDashboard.md`).
**If SportsActivity subscription/booking payment logic changes:**
- Must update BOTH `SaEventListenerService` (Cashier request-queue path) AND `SaPaymentService` (direct
Payments-module path) — see `SportsActivity.md` Risk Areas. Verify which controller actions use which
path first.
**If either system's academy/contract/pool-scheduling tables change:**
- Check both the legacy module (`Academies`/`AcademyContracts`/`PoolManagement`/`TrainingGroups`) and the
current one (`SportsActivity`'s `sa_academies`/`sa_academy_contracts`/pool-grid/waitlist) — they are
independently maintained, not layered.
### Configuration Dependencies (Sports Activity Cluster)
| Config / rule key | Consumers | Notes |
|---|---|---|
| `system_config.sa.absence_threshold` | `SportsActivity\AttendanceRuleService` (`sa_attendance`), `SportsActivity\TrainingAttendanceService` (`sa_training_attendance`) | Two services, two events, two tables — intentionally separate. Their fallback default is now shared: `SaConstants::DEFAULT_ABSENCE_THRESHOLD = 3`. Change it there, not in either service. |
| `RuleEngine` `membership.athletic_conversion` (via `Members\MembershipRulesService::getAthleticMemberConversionRules()`) | `Sports\SportsController::store()` (fee charged), `Sports\SportsConversionCalculator::checkEligibility()` (fee previewed) | Single source of truth for the athletic conversion percentage. |
| `RuleEngine` `SPORTS_CONVERSION_FEE` | **none** | Seeded business rule, now orphaned. Editing it has no effect. Candidate for retirement. |
| `RuleEngine` `SPORTS_MIN_YEARS` | `Sports\SportsConversionCalculator` | Still separate from `getAthleticMemberConversionRules()['min_playing_years']` — same divergence pattern the fee % used to have. |
**If first-month billing behaviour changes, there are now THREE writers of first-month subscription rows:**
- `SportsActivity\SaEventListenerService::handleEnrollmentPaid()` — direct group enrollment (`period_start = today`).
- `SportsActivity\RegistrationWizardService::completeRegistration()` — wizard (`period_start = 1st of month`, chosen to dedupe against the batch).
- `SportsActivity\SubscriptionGeneratorService::generateForMonth()` — monthly batch (`period_start = 1st of month`), whose idempotency key is `player_id + group_id + period_start`.
The first bullet's `period_start` convention does **not** match the batch's key, so that path can still be
double-billed by a subsequent `generate` run. Reconcile all three before touching this area.
For the legacy generation the equivalent writers are `ActivitySubscriptions\ActivitySubscriptionService::generateMonthlySubscriptions()`
and `PlayerAffairs\EventListeners.php`'s `academy.enrollment_created` handler (now actually reachable).
**If a decision is made to consolidate the two generations:**
- `AcademyContracts\SettlementService` is the one confirmed place that already treats both systems'
revenue as equally authoritative — start impact analysis there.
- `SportsDashboard` would need to be re-pointed at `sa_*` tables (or retired in favor of
`SportsActivity\SaReportController`/`FacilityDashboards`).
---
## Dashboard Module ## Dashboard Module
The Dashboard is a **pure read consumer**. It writes nothing, dispatches no events, and owns The Dashboard is a **pure read consumer**. It writes nothing, dispatches no events, and owns
......
# Sports Module — Architecture Map
## Module Purpose
Manages the **"athletic/sports member"** membership sub-type — members whose `membership_type = 'sports'`
(distinct from the club's regular `working` membership tier). Tracks each such member's federation
registration, sport/discipline, and years of competitive service, and determines/tracks their eligibility
to **convert** into a full `working` membership after a minimum service period.
**Not related to activity bookings, coaching, or facility scheduling** — that is entirely owned by
`SportsActivity` (see `docs/architecture-maps/SportsActivity.md`). Despite the similar name, `Sports` is a
small satellite of the **Members** module (a special membership category), not part of the sports-activity
booking/coaching subsystem.
## System Responsibilities
1. Register a sports-member profile for a member (`sports_members` row) with federation/discipline info.
2. Collect the sports-membership fee at registration (percentage of the `working`-tier membership price).
3. Track `years_of_service` and evaluate conversion eligibility against a configurable minimum (`RuleEngine`).
4. Preview the conversion fee (JSON endpoint only — see Risk Areas, no write-side conversion exists).
## File Structure
```
app/Modules/Sports/
├── bootstrap.php — permissions only (no menu, no event listeners)
├── Routes.php
├── Controllers/
│ └── SportsController.php — index, create, store, checkConversion
├── Models/
│ └── SportsMember.php — table: sports_members
├── Services/
│ └── SportsConversionCalculator.php — eligibility + fee preview
└── Views/
├── index.php — list of active sports members
└── create.php — registration form
```
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sports` | `SportsController@index` | `sports.view` |
| GET | `/members/{memberId}/sports/create` | `SportsController@create` | `sports.add` |
| POST | `/members/{memberId}/sports` | `SportsController@store` | `sports.add` |
| POST | `/members/{memberId}/sports/check-conversion` | `SportsController@checkConversion` | `sports.convert` |
## Permissions
`sports.view`, `sports.add`, `sports.convert` (registered in `bootstrap.php`). No sidebar menu entry
registered, no `EventBus::listen()` calls.
## Database Schema (migration-derived — NOT live-DB verified; see Database Truth Rule)
### `sports_members` (`Phase_10_003_create_sports_members_table.php`)
| Column | Notes |
|---|---|
| id | BIGINT UNSIGNED PK |
| member_id | FK → `members.id` |
| sport_name | VARCHAR(200) |
| federation_name, federation_registration | nullable |
| registration_date | DATE nullable |
| years_of_service | INT UNSIGNED, default 0 — the eligibility driver |
| highest_competitive_level | محلي/إقليمي/قومي/دولي/أولمبي |
| is_conversion_eligible, conversion_requested | TINYINT flags |
| conversion_date, conversion_fee, conversion_receipt | set only if a conversion write-path existed (it doesn't yet) |
| status | default `active` |
| standard archive/audit columns | is_archived, archived_at/by, timestamps, created_by/updated_by |
Indexes: `member_id`, `status`.
### `sport_disciplines` (`Phase_17_001_create_sport_disciplines_table.php`)
Used only to populate the `create.php` discipline dropdown (`id, name_ar`). Not the same table as
`SportsActivity`'s `sa_disciplines`.
## Business Rules
### Registration (`SportsController::store()`)
Computes `sports_membership_fee` at registration time as a percentage of the member's `membership_value`
(falling back to `pricing_configs` for `membership_type='working'` if the stored value is 0), using
`Members\Services\MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage']`
(default 50%). Creates a payment request via `Cashier\Services\PaymentRequestService`. Dispatches
`sports.registered` (see Risk Areas — no listener consumes it).
### Conversion Eligibility (`SportsConversionCalculator::checkEligibility()`)
"Conversion" = upgrading the athlete's `sports`-type membership into the standard, more expensive
`working` tier once `years_of_service` clears a minimum.
1. Load `sports_members` row; no row → ineligible.
2. Compare `years_of_service` to `RuleEngine::get('SPORTS_MIN_YEARS')` (default 8); short → returns
`remaining` years needed.
3. If eligible on years: resolve the member's qualification code (`qualifications.code`, default `high`)
and call `Pricing\Services\PricingEngine::getMembershipPrice($branchId, $qualCode)` for the target
`working`-tier value.
4. `conversion_fee = new_membership_value * (MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage'] ?? 50) / 100`
(via `bcmul`/`bcdiv`). **Changed** — previously read `RuleEngine::get('SPORTS_CONVERSION_FEE')['percentage']`,
a second independently-configurable source that could drift from what registration actually charges.
5. Returns eligibility flag, years, min_years, target membership value, fee %, and fee — JSON preview only.
**This is preview-only.** `checkConversion()` (the `sports.convert`-gated endpoint) never writes anything —
no route/controller performs the actual conversion (member type change, fee payment request, or updating
`sports_members.conversion_requested/conversion_date/conversion_fee`). The feature is incomplete.
## Cross-Module Integration
- `SportsMember` model class is used only inside `app/Modules/Sports/` — no other module imports it.
- The **`sports_members` table** (raw SQL) is read by:
- `Archive\Services\ArchiveService` — loads sports records when archiving a member.
- `Members\Views\show.php` — shows a "complete sports profile" prompt; maps
`membership_type='sports'``payment_type='sports_membership_fee'`.
- `Audit\Services\AuditService` — Arabic label mapping for audit-log display.
- The **`sports_membership_fee`** payment-type string (not the table) is a recognized "membership
activation" payment type across `Cashier`, `Treasury`, `Dashboard` widgets, and
`Members\Services\BillingService` / `MembershipPaymentGuard`.
- Depends on: `Members\Services\MembershipRulesService` (now used by **both** `SportsController` and
`SportsConversionCalculator`), `Cashier\Services\PaymentRequestService`, `Rules\Services\RuleEngine`
(now only for `SPORTS_MIN_YEARS`), `Pricing\Services\PricingEngine`.
## Risk Areas / Technical Debt
1. **Dead event**: `sports.registered` dispatched in `SportsController::store()` has no listener anywhere.
2. **Incomplete feature**: no write-side conversion action exists; `sports.convert` only gates a JSON
eligibility/fee preview.
3. ~~**Two divergent fee-percentage sources**~~ — **RESOLVED**. Both the registration charge
(`SportsController::store()`) and the conversion-fee preview (`SportsConversionCalculator::checkEligibility()`)
now read the single source of truth
`Members\Services\MembershipRulesService::getAthleticMemberConversionRules()['conversion_percentage']`
(which itself resolves `RuleEngine::get('membership.athletic_conversion')`, default `'50'`).
The `SPORTS_CONVERSION_FEE` business-rule row (seeded by
`database/seeds/Phase_05_001_seed_all_business_rules.php`, value
`{"percentage":"50.00","min_years":8}`) is now **unreferenced by any code**. It was deliberately left in
place — deleting seeded business-rule data is a separate, riskier change. If someone edits that rule
expecting it to affect fees, nothing will happen; consider retiring the row.
4. **Remaining divergence — minimum years** (not changed, flag for follow-up): eligibility still uses
`RuleEngine::get('SPORTS_MIN_YEARS')['years']` (default 8), while
`getAthleticMemberConversionRules()` independently exposes `min_playing_years` (also default 8) from
`membership.athletic_conversion`. Same two-sources-of-truth pattern as the fee percentage was, one level
down. Unifying it was out of scope for the fix pass that resolved item 3.
# SportsActivity Module — Architecture Map
## Module Purpose
The club's **actively-developed** academy/coaching/facility-booking subsystem (route prefix `/sa`, API
prefix `/api/sa`). Members or non-members register as `sa_players`, get grouped (`sa_groups`) under a
`sa_programs`/`sa_disciplines` hierarchy, scheduled weekly (`sa_group_schedule`), tracked for training
attendance, billed monthly (`sa_subscriptions`), and issued a physical/printed player card
(`sa_player_cards`). Separately runs an hourly facility-booking system (`sa_bookings`) for
courts/pools/lanes — a distinct concept from group subscriptions (see "Booking vs Subscription" below).
Also owns: lockers, pool scheduling (three sub-systems — see Risk Areas), waitlist, academy/institution
contracts, makeup sessions, gate access control, and recreational games.
This is the **newest generation** of the club's activity/academy system (table prefix `sa_*`, migrations
`Phase_70+`), running in parallel with the older `ActivitySubscriptions`/`Academies`/`PlayerAffairs`
system (`Phase_18-45`, unprefixed tables) — see `docs/architecture-maps/ActivitySubscriptions.md` and the
Dependency Graph for how the two coexist.
## System Responsibilities
1. Player registration (own `sa_players` table, distinct from `members`/`players`), medical certificate
tracking, physical player cards with QR codes.
2. Program/discipline/group hierarchy, weekly scheduling, enrollment, waitlist, transfers, pause/resume.
3. Facility hourly bookings (courts, lanes, pitches) — separate revenue stream from group subscriptions.
4. Monthly subscription billing for group enrollment, with proration and (in the Registration Wizard
path only) advance-payment/sibling discounts.
5. Training attendance (per-group-session) and per-booking attendance, with absence-threshold alerts.
6. Coaches, coach assessments, coach payment models.
7. Lockers (rental lifecycle with eviction), pool scheduling/reservations/tickets, swimming-specific
registration, gate access control (QR/card scanning), recreational games, academy/institution B2B
contracts, makeup sessions for missed training.
## File Structure (high-level; module is large — 55+ controllers)
```
app/Modules/SportsActivity/
├── bootstrap.php — permissions (60+ keys), sidebar menu (40+ items), EventBus listeners
├── Routes.php — ~394 lines, /sa/* web + /api/sa/* JSON
├── SaConstants.php — single source of truth for status/type enums + shared
│ config fallbacks (DEFAULT_ABSENCE_THRESHOLD)
├── Controllers/
│ ├── DashboardController, DisciplineController, FacilityController, FacilityUnitController
│ ├── CoachController, PlayerController, PlayerDocumentController, PlayerAssignmentController
│ ├── ProgramController, GroupController, ScheduleController
│ ├── BookingWizardController, BookingController
│ ├── PricingController, SubscriptionController
│ ├── AttendanceController, TrainingAttendanceController
│ ├── RegistrationWizardController, SaCardController, CoachAssessmentController
│ ├── AcademyPricingController, PlayerLifecycleController, SaReportController
│ ├── LockerController, LockerRentalController, WaitlistController, GateController
│ ├── AcademyController, AcademyContractController, InstitutionController, MakeupSessionController
│ ├── GameController
│ ├── Swimming/{SwimmingRegistrationController, SwimmingAssignmentController, PoolReservationController, CoachController}
│ ├── PoolGridController, PoolTicketController, MirrorController
│ └── Api/{MirrorApiController, PoolGridApiController, PoolGridTemplateApiController}
├── Models/ (Player, Group, GroupPlayer, GroupSchedule, Booking, Subscription, Program, Discipline,
│ Coach, Facility, FacilityUnit, TimeBracket, PricingRule, Academy, LockerRental, ...)
└── Services/
├── EnrollmentService, GroupTransferService, PlayerLifecycleService
├── BookingService, SlotAvailabilityService, ConflictDetectionService
├── PricingCalculatorService, DiscountCalculatorService
├── SubscriptionGeneratorService, SaPaymentService
├── AttendanceRuleService, TrainingAttendanceService
├── RegistrationWizardService, CardRenewalService, NumberGeneratorService
├── SaEventListenerService
├── WaitlistAutoOfferService, MakeupSessionService
├── GateAccessService, MirrorStateService
└── Swimming/*, PoolGridService, PoolReservationService, PoolTicketService
```
## Routes (key groups; full table in `Routes.php`)
| Area | Path prefix | Controller | Permission prefix |
|---|---|---|---|
| Dashboard | `/sa` | `DashboardController` | `sa.dashboard` |
| Disciplines | `/sa/disciplines` | `DisciplineController` | `sa.discipline.*` |
| Facilities / Units | `/sa/facilities`, `/sa/facilities/{fid}/units` | `FacilityController`, `FacilityUnitController` | `sa.facility.*` |
| Time Brackets | `/sa/facilities/{fid}/brackets` | `FacilityController` | `sa.facility.manage` |
| Coaches | `/sa/coaches` | `CoachController` | `sa.coach.*` |
| Players / Documents | `/sa/players`, `/sa/players/{pid}/documents` | `PlayerController`, `PlayerDocumentController` | `sa.player.*`, `medical.board.approve` |
| Assignments | `/sa/assignments` | `PlayerAssignmentController` | `sa.player.assign` |
| Programs | `/sa/programs` | `ProgramController` | `sa.program.*` |
| Groups | `/sa/groups` (+ enroll/force-enroll/remove-player/transfer-player/schedule/generate-sessions) | `GroupController` | `sa.group.*` |
| Schedule | `/sa/schedule`, `/daily/{date}`, `/weekly`, `/generate`, `/copy`, `/shift-time` | `ScheduleController` | `sa.schedule.*` |
| Booking Wizard | `/sa/booking-wizard` | `BookingWizardController` | `sa.booking_wizard.use` |
| Bookings | `/sa/bookings` (+ cancel/postpone/checkin/checkout) | `BookingController` | `sa.booking.*` |
| Pricing | `/sa/pricing`, `/rules` | `PricingController` | `sa.pricing.*` |
| Subscriptions | `/sa/subscriptions` (+ generate/pay/void/exempt) | `SubscriptionController` | `sa.subscription.*` |
| Attendance | `/sa/attendance`, `/record/{bookingId}`, `/report` | `AttendanceController` | `sa.attendance.*` |
| Training Attendance | `/sa/training-attendance`, `/record/{groupId}`, `/report` | `TrainingAttendanceController` | `sa.attendance.*` |
| Registration Wizard | `/sa/registration/*` | `RegistrationWizardController` | `sa.registration.*` |
| SA Cards | `/sa/cards` (+ suspend/revoke/reactivate/renew/print) | `SaCardController` | `sa.card.*` |
| Coach Assessment | `/sa/coach-assessment` | `CoachAssessmentController` | `sa.coach_assessment.*` |
| Player Lifecycle | `/sa/players/{pid}/groups/{gid}/pause|resume|extend-grace` | `PlayerLifecycleController` | `sa.enrollment.manage` |
| Reports | `/sa/reports/players|finance` (+ export-csv/pdf) | `SaReportController` | `sa.report.*` |
| Lockers | `/sa/lockers`, `/sa/locker-rentals` | `LockerController`, `LockerRentalController` | `sa.locker*` |
| Waitlist | `/sa/waitlist` (+ offer) | `WaitlistController` | `sa.waitlist.manage` |
| Gate | `/sa/gate`, `/scan`, `/log`, `/report` | `GateController` | `sa.gate.*` |
| Pool Grid | `/sa/pool-grid/{id}` + `/api/sa/pool-grid/*` | `PoolGridController`, `Api\PoolGridApiController` | `sa.pool_grid.*` |
| Pool Reservations | `/sa/swimming/pool-reservations`, `/api/sa/swimming/pool-reservations/*` | `Swimming\PoolReservationController` | — |
| Pool Tickets | `/sa/pool-tickets` | `PoolTicketController` | `sa.pool_ticket.*` |
| Swimming | `/sa/swimming*`, `/coaches`, `/register`, `/assign` | `Swimming\*` | `sa.swimming.*` |
| Academies / Contracts | `/sa/academies`, `/sa/academies/{aid}/contracts`, `/sa/academy-contracts/{id}/approve` | `AcademyController`, `AcademyContractController` | `sa.academy.*`, `sa.contract.*` |
| Institutions | `/sa/institutions` | `InstitutionController` | `sa.institution.*` |
| Makeup Sessions | `/sa/makeup-sessions` | `MakeupSessionController` | `sa.makeup.*` |
| Mirror | `/sa/mirror`, `/api/sa/mirror/{id}/*` | `MirrorController`, `Api\MirrorApiController` | `sa.mirror.view`, `sa.booking.manage`, `sa.schedule.manage` |
| Games | `/sa/games` | `GameController` | `sa.game.*` |
| Core JSON APIs | `/api/sa/schedule/availability|conflicts`, `/bookings/price-preview`, `/players/search`, `/member-lookup`, `/groups/quick-create|search`, `/coaches/search`, `/disciplines/list`, `/programs/{id}/pricing`, `/subscriptions/preview|pause|unpause` | — | mixed |
## Permission Model
`PermissionRegistry::register('sports_activity', [...])` in `bootstrap.php` — 60+ keys under the `sa.*`
namespace (see route table above for the main ones), plus `sa.medical.approve` (**deprecated** — superseded
by cross-module `medical.board.approve`, which current routes already use). Full sidebar menu (40+ items)
is registered via `MenuRegistry`.
## EventBus
### Dispatched from this module
| Event | Source |
|---|---|
| `sa.absence_threshold.reached` | `TrainingAttendanceService` (private `checkAbsenceThreshold`) |
| `sa.player.absence_threshold` | `AttendanceRuleService::processMonthlyAbsenceReport()`**note: near-duplicate of the above, see Risk Areas** |
| `sa.card.issued` | `RegistrationWizardService::generateCard()` |
| `sa.card.renewed` | `CardRenewalService::applyRenewal()` |
| `sa.medical_grace.expired` | `PlayerLifecycleService::checkMedicalGraceExpired()` |
| `sa.player.paused` / `sa.player.resumed` | `PlayerLifecycleService` |
| `sa.player.transferred` | `GroupTransferService` |
| `sa.registration.completed` | `RegistrationWizardService::completeRegistration()` |
| `sa.waitlist.offer` | `WaitlistAutoOfferService::offerNextInLine()` |
| `sa.gate.access_denied` | `GateAccessService::checkAccess()` |
### Listened (registered in `bootstrap.php`)
| Event | Source module | Handler |
|---|---|---|
| `payment_request.completed` | Cashier | `SaEventListenerService::handlePaymentCompleted` (priority 60) — dispatches to per-entity handlers keyed by `related_entity_type`: `sa_registration_form`, `sa_registrations`, `sa_subscriptions`, `sa_bookings`, `sa_group_players`, `sa_player_cards`, `sa_game_tickets`, `sa_pool_tickets`, `sa_pool_reservations` (last one is a hardcoded string, not an `SaConstants` constant) |
| `payment.voided` | Payments | `SaEventListenerService::handlePaymentVoided` (priority 60) — symmetric reversal across the same tables; for enrollment calls `EnrollmentService::deactivateEnrollment()` |
| `sa.card.expiry_reminder`, `sa.subscription.overdue`, `sa.player.transferred`, `sa.player.absence_threshold` | self | inline closures → SMS via `Notifications\Services\SmsNotificationService` |
| `sa.gate.access_denied` | self | inline closure → `Logger::warning` |
| `sa.waitlist.offer` | self | inline closure → SMS |
**`sa.subscription.overdue` is listened for but never dispatched anywhere found in Services/Controllers**
likely dead, or dispatched from an un-located cron/job file.
## Business Rules
### Enrollment — two parallel entry points feeding the same `sa_group_players`/`sa_subscriptions` tables
**A. Direct group enrollment** (`EnrollmentService::enroll()`, used by `GroupController@enroll`):
1. Validate player, check `medical_status` (not-fit sets a `medical_grace_deadline`, config
`sa.medical_grace_days` default 14 — doesn't block enrollment).
2. Validate group active, optional age-range check.
3. Not already enrolled; capacity check against **`sa_programs.max_capacity`** (moved off `sa_groups` by
`Phase_86_001`).
4. Fee = `sa_programs.monthly_fee_member`/`monthly_fee_nonmember` by `player_type`.
5. Transaction: insert `sa_group_players` as `pending_payment`, create a Cashier payment request
(`payment_type=sports_registration`, `related_entity_type=sa_group_players`).
6. On `payment_request.completed``SaEventListenerService::handleEnrollmentPaid()`. **Precise split
(corrected):** `EnrollmentService::activateEnrollment()` only flips `sa_group_players` to `active` and
bumps `sa_groups.current_count`/`is_full`. The **first paid `sa_subscriptions` row is created by
`handleEnrollmentPaid()` itself**, immediately after that call — not inside `activateEnrollment()`.
Anything invoking `activateEnrollment()` directly therefore gets no subscription row.
Note this row is written with `period_start = date('Y-m-d')` (today), which does **not** match
`SubscriptionGeneratorService::generateForMonth()`'s idempotency key (`period_start = 'YYYY-MM-01'`), so
the monthly batch will not recognise it and can bill the same month twice. Pre-existing; not changed.
7. `forceEnroll()` — same flow, skips capacity check (`sa.group.force_enroll`), `force_enrolled=1`.
8. `enrollByCoach()` — coach quick-enroll UI; finds/creates an available group under a program taught by
that coach, cloning schedule from the most recent group if a new one is created.
**B. Registration Wizard** (`RegistrationWizardService`, `/sa/registration/*`):
1. `startRegistration()` — dedupe/create `sa_players` (match by national_id, fall back to member_id only if
national IDs agree), parse DOB/gender via `NationalIdParser`, generate serial `SAP-00001`. Flat
registration fee (50 EGP member / 100 EGP non-member, **hardcoded** in `calculateFees()`).
2. `capturePhoto()`, `selectProgram()`/`selectGroup()` — subscription = `monthly_fee × months`, then
`DiscountCalculatorService::calculateDiscounts()` (advance-payment ≥3 months + sibling discounts, from
`sa_discount_rules`).
3. `submitFormPayment()` — Cashier request for `total_fees` (`sa_registration_fee`,
`related_entity_type=sa_registration_form`).
4. `completeRegistration()` — inserts `sa_group_players` as **already `active`** (bypassing
`pending_payment`) with `activated_by_payment_id`. **Now also creates the first month's
`sa_subscriptions` row, already marked `paid`**, mirroring `handleEnrollmentPaid()`'s record shape
(`subscription_number` via `NumberGeneratorService`, `payment_status='paid'`, `paid_at`, `paid_amount`,
`payment_id`). Amount comes from `sa_registrations.subscription_amount`, which `selectGroup()` already
stores **post-discount** (`DiscountCalculatorService` advance-payment + sibling discounts carry through).
Dispatches `sa.registration.completed`.
**Deliberate deviation from `handleEnrollmentPaid()`**: `period_start` is normalised to `date('Y-m-01')`
(not `date('Y-m-d')`) so the row matches `SubscriptionGeneratorService::generateForMonth()`'s dedupe key
and the batch skips the month instead of double-billing it. Guarded by an existence check on
player+group+period_start, and skipped when `subscription_amount` is 0.
**Known residual gap**: when `subscription_months > 1` the player pays for N months upfront but only
**one** month-1 row is written, so months 2..N are still billed again by the monthly batch. Pre-existing
modelling gap in the wizard (it has no multi-month subscription rows); out of scope of the fix that
closed the month-1 hole, but it is the next thing to address here.
5. `generateCard()` — creates `sa_player_cards` (QR via `Carnets\Services\QRCodeGenerator`), sets
`valid_until` = now + months − 1 day, `sa_players.card_status=active`, dispatches `sa.card.issued`.
**Resolved inconsistency**: path A auto-bills the first month on enrollment payment (inside
`SaEventListenerService::handleEnrollmentPaid`); path B (wizard) previously did not, so a wizard-registered
player's first month could go silently uncharged until the next manual/cron `generate` run. Path B now
writes its own first-month paid row. The two paths still differ in `period_start` convention — see the
notes on each above.
### Booking vs Subscription (distinct concepts sharing the same facility/scheduling layer)
- **Booking** (`sa_bookings`, `BookingService`) = a single dated/timed reservation of a `sa_facility_unit`.
`booking_type='hourly'` (ad-hoc, priced via `PricingCalculatorService` per-person-per-hour using
time-bracket rules) vs `'training'` (recurring, `total_amount=0`, reserves the slot for a group's weekly
session — the player's actual cost is the subscription, not the booking).
- **Subscription** (`sa_subscriptions`) = the recurring monthly fee a player owes for group enrollment,
independent of any specific session/booking. One row per player+group+month.
### Pricing
`PricingCalculatorService::calculate()`**hourly bookings only**. Resolve day-of-week → matching
`sa_time_brackets` row (peak preferred, `JSON_CONTAINS` on `days_of_week_json`) → matching
`sa_pricing_rules` row (unit + bracket + group-size range + effective date) → price/person =
`price_per_person_organization` (org bookers) else `price_per_person_member`/`nonmember` → total =
price × participants × duration-hours. Returns null if no bracket/rule found. **Group/program subscription
pricing is entirely separate** — flat `sa_programs.monthly_fee_member/nonmember`, no discipline/age/branch
factor.
### Subscription Generation
`SubscriptionGeneratorService::generateForMonth()` — static batch method, triggered manually via
`POST /sa/subscriptions/generate` (`sa.subscription.generate`); **not confirmed wired to a cron**. For each
active `sa_group_players` row: skip if already billed this month or month is in `paused_months` JSON; else
fee from `sa_programs.monthly_fee_*`, **50% proration** if it's the player's first subscription and they
enrolled after the 15th; flags (not blocks) on medical non-fit. **No discounts applied** here (unlike the
Registration Wizard path).
### Attendance
Two separate concepts/tables: `sa_attendance` (per-booking, `AttendanceController`) vs
`sa_training_attendance` (per-group+date bulk roster, `TrainingAttendanceController`,
`TrainingAttendanceService::recordBulk()` — delete-then-reinsert semantics, not upsert).
### Player Lifecycle (`PlayerLifecycleService`) — enrollment-level, not player-level
States in practice: `pending_payment``active` → (`withdrawn` | `transferred`), plus an orthogonal
"paused-for-month" JSON array (`sa_group_players.paused_months`, capped by `sa.max_pause_months` default 3)
and automatic force-withdrawal via `checkMedicalGraceExpired()` when `medical_grace_deadline` passes and the
player is still not medically cleared (dispatches `sa.medical_grace.expired`).
### SA Card Renewal
`CardRenewalService::renewCard()` — configurable fee (`system_config.sa.card_renewal_fee`, default 25 EGP)
via Cashier (`payment_type=sa_card_renewal`); on completion, `applyRenewal()` extends `valid_until` by N
months from the later of today/current `valid_until`.
### Waitlist
`sa_waitlist` (`waiting`/`offered`/`accepted`/`expired`/`cancelled`). `WaitlistAutoOfferService::offerNextInLine($groupId)` — the real business-rule engine: checks group has open capacity, pulls oldest
`waiting` entry (`position, created_at`), marks `offered` with 48h `expires_at`, dispatches
`sa.waitlist.offer` (→ SMS). Triggered only from `EnrollmentService.php` (when a seat frees up).
`acceptOffer()` validates non-expiry then calls `EnrollmentService::enroll()`.
### Lockers
`sa_lockers` (asset) + `sa_locker_rentals` (tenancy). Rental lifecycle: `active → grace_period →
pending_eviction → evicted` (or `completed`/`cancelled`), with `renewed_from_id` renewal chaining.
`storeEvict()` requires a non-empty `eviction_reason`. No automated grace→eviction state-machine transition
found in the controllers read (likely cron-driven, unverified).
### Academy / Institution / Program / Discipline hierarchy
- **Discipline** (`sa_disciplines`) — the sport itself.
- **Program** (`sa_programs`) — a specific offering under a discipline, carries pricing/age range.
- **Group** (`sa_groups`) — the scheduled class/roster instance of a program.
- **Academy** (`sa_academies`) — a coaching provider (`internal`/`external`), tied to a discipline.
- **AcademyContract** (`sa_academy_contracts`) — commercial agreement with an academy (`revenue_share` /
`fixed_rent` / `hybrid`, `club_commission_pct`, lifecycle `draft → pending_approval → active →
suspended/expired/terminated`, requires `sa.contract.approve`).
- **Institution** (`sa_institutions`) — B2B bulk facility renters (schools, companies) — distinct from
academies; books via `sa_bookings.institution_id`.
### Makeup Sessions
`MakeupSessionService::createFromAbsence()` — only created from a recorded `absent`/`excused`
`sa_training_attendance` row (not automatic). Expiry window `system_config.sa.makeup_expiry_days` (default
30 days). `complete()` writes back a synthetic `sa_training_attendance` row with `status='makeup'`.
`expireOverdue()` is a batch sweep (cron wiring not verified in this pass).
### Gate Access
`GateAccessService::checkAccess()` — parses `THECLUB:SA:{cardNumber}` QR or raw `SAC-YYYY-NNNNNN`, validates
`sa_player_cards` status (`suspended`/`revoked`/`expired`/`valid_until`), grants only for
`active`/`temporary`. Every scan logged to `sa_gate_access_log`. Denials dispatch `sa.gate.access_denied`.
### Pool Subsystem — three parallel scheduling models live simultaneously (see Risk Areas)
1. **PoolGrid** (`sa_pool_zone_bookings`, `PoolGridService`) — row×column grid per facility/time-slot;
admin assigns cells (`training`/`blocked`/`maintenance`/`hourly`/`open_access`) with conflict detection;
supports templates.
2. **PoolReservation** (`sa_pool_reservations`, `Swimming\PoolReservationController`) — commercial lane
rental for freelance coaches/bookers, N `sessions_total`, hard-coded lane capacities (12/8/24 for
`lane_50m`/`lane_25m`/`lane_mix`). Auto-creates a synthetic `sa_groups` row
(`source_type='pool_reservation'`) to represent the reservation.
3. **PoolTicket** (`sa_pool_tickets`, `PoolTicketService`) — single-entry walk-in tickets for `open_access`
zones, member/non-member differential pricing, occupancy counters.
### Mirror
`MirrorStateService`**not a cache/sync mechanism despite the name**; a live on-demand aggregation query
merging `sa_bookings` + `sa_group_schedule` (+ `sa_pool_zone_bookings` if the facility has grid dimensions)
into a unified per-timeslot-per-unit grid (`free`/`partial`/`full`/`training`/`booked`/`blocked`), used by
front-desk/ops staff. `Api\MirrorApiController` layers write actions (quick-book, move, swap, bulk-block,
templates) on top for drag-and-drop scheduling.
### Payment Integration — two coexisting patterns
- **Request-then-collect** (`Cashier\Services\PaymentRequestService`) — used by `EnrollmentService`,
`CardRenewalService`, `CoachAssessmentService`, `RegistrationWizardService`, `PoolTicketService`,
`PoolReservationService`, `GameController`, `BookingWizardController`. Completion fires the generic
`payment_request.completed` event → `SaEventListenerService`.
- **Collect-now** (`Payments\Services\PaymentService`) — used directly by `SaPaymentService::paySubscription()`/`payBooking()`/`voidSubscriptionPayment()` and `BookingService::cancel()` (auto-void). Synchronous,
no event-driven completion.
**⚠ This dual pattern means a change to payment-completion behavior must be applied in both places**
`SaEventListenerService::handleSubscriptionPaid`/`handleBookingPaid` (Cashier path) AND
`SaPaymentService::paySubscription`/`payBooking` (direct path) — since a subscription/booking could be paid
via either route depending on which controller action fires. **Verify which controller actions call which
path before touching payment logic in this module.**
## Cross-Module Integration
- Depends on: `Cashier\Services\PaymentRequestService`, `Payments\Services\PaymentService`,
`Members\Services\MembershipValidationService`, `Notifications\Services\SmsNotificationService`,
`Carnets\Services\QRCodeGenerator`, `Shared\Services\PdfExportService`.
- Consumed by: `AcademyContracts\Services\SettlementService` (sums `sa_group_players`/`sa_groups`/
`sa_coaches` revenue alongside the legacy `activity_subscriptions` system in the same settlement
calculation — see Dependency Graph).
- Payment types (`sports_registration`, `hourly_booking`, `sa_form_fee`, `sa_subscription`,
`sports_subscription`, `sa_registration_fee`, `sa_game_ticket`, `sa_pool_ticket`, `pool_reservation`) are
whitelisted in `Cashier\Services\PaymentRequestService` as guest-payable (no `member_id` required), and
bundled together in `Treasury\Services\TreasuryService` and `Dashboard\Config\widgets.php` pending-collection
filters.
## Risk Areas / Technical Debt
1. **Three generations of pool-scheduling code live simultaneously**: `PoolManagement` module (oldest,
`/pool/*`, tables `pool_schedules`/`pool_bookings`/`pool_configurations` — its own `Routes.php` admits
mid-migration: *"Pool Grid & Schedules — migrated to unified FacilityGrids module"*) → `FacilityGrids`
module (mid-gen, `/facility-grids/*`, own `GridStateService`/`PoolHourPlanService`/`PoolFinancialService`)
→ SportsActivity's Mirror/PoolGrid/PoolReservation/PoolTicket (newest, `sa_pool_*` tables). Unclear from
code alone which system front-line staff actually use day-to-day — needs product/eng reconciliation.
2. **Duplicate Academy/Contract systems**: standalone `Academies` module (`/academies`, `academy.*` perms)
and standalone `AcademyContracts` module (`/academy-contracts`, `/academy-settlements`,
`academy_contract.*` perms — includes a Settlement system with `guaranteeReport`/`generate`/`approve`
with **no equivalent** in SportsActivity's `AcademyContractController`) run in parallel with
`sa_academies`/`sa_academy_contracts`. Different table sets, different permission namespaces.
3. **Duplicate Group/Waitlist system**: standalone `TrainingGroups` module (`/training-groups`,
`/waiting-list`, `training_group.*` perms) is a near-identical group+waitlist system
(`WaitingListController@offer/@cancel` mirrors `WaitlistController` exactly) on completely different
tables from `sa_groups`/`sa_waitlist`.
4. **Two independently-implemented absence-threshold checkers**: `AttendanceRuleService`
(`sa_attendance`, event `sa.player.absence_threshold`) vs `TrainingAttendanceService`
(`sa_training_attendance`, event `sa.absence_threshold.reached`). The **fallback-default mismatch is
RESOLVED** — both previously read config key `sa.absence_threshold` but fell back to 3 and 5
respectively; they now share `SaConstants::DEFAULT_ABSENCE_THRESHOLD = 3`. The two services, their two
event names and their two source tables are **intentionally left separate** (different attendance
concepts). Any future change to the default must be made in `SaConstants` only.
5. ~~**Registration path inconsistency**~~ — **RESOLVED**: `RegistrationWizardService::completeRegistration()`
now creates the first month's paid `sa_subscriptions` row (see Business Rules above).
6. **Dual payment-completion pattern** (Cashier request-queue vs. direct Payments module) — see Payment
Integration above. Most significant risk area for any change to payment logic in this module.
7. `sa.medical.approve` permission explicitly deprecated in favor of `medical.board.approve` — legacy cruft,
candidate for removal once confirmed unused elsewhere.
8. `sa_groups.monthly_fee_member/nonmember` columns still exist (original `Phase_70_013` migration) but
pricing authority moved to `sa_programs` in `Phase_86_001` — columns appear vestigial (absent from the
`Group` model's `$fillable`); verify dead-column status against the live DB before dropping.
9. `sa.subscription.overdue` event listened for but no dispatch call found — likely dead or dispatched from
an unlocated cron/job file.
10. `PricingCalculatorService::calculate()` accepts a legacy `bool|string $isMemberOrBookerType` param — the
bool branch appears unused by current callers.
11. `sa_pricing_rules.price_per_person_organization` and `sa_discount_rules` table are referenced in code
but their creating migrations were not individually located in this research pass — confirm against live
DB before relying on their exact schema.
12. **Database Truth Rule caveat**: most schema details in this document were read from Model/Service PHP
code (which reflects what the app actually queries) rather than from a live DB connection (unavailable
in this research pass) — more reliable than migrations alone, but still needs live verification before
any schema-dependent change.
# SportsDashboard Module — Architecture Map
## Module Purpose
Read-only analytics/BI dashboard (club-level, per-sport, per-facility) showing revenue, player
demographics, discipline/facility breakdowns, coach session counts, and popular booking time slots, with
PDF export. It is a **leaf/reporting module** — nothing depends on it, and it writes nothing.
**Critical caveat**: it queries **legacy, non-`sa_`-prefixed tables**
(`sport_disciplines`, `facilities`, `players`, `coaches`, `reservations`, `activity_subscriptions`,
`facility_zone_schedules`, `facility_time_slots`) rather than the current `sa_*`-prefixed tables used by
the actively-developed `SportsActivity` module. It may be stale/superseded — see Risk Areas.
## File Structure
```
app/Modules/SportsDashboard/
├── bootstrap.php — permissions only, no menu, no listeners
├── Routes.php
├── Controllers/
│ └── SportsDashboardController.php — clubLevel, sportLevel, facilityLevel, export
├── Services/
│ └── DashboardMetricsService.php
└── Views/
├── club_dashboard.php
├── sport_dashboard.php
└── facility_dashboard.php
```
## Routes
| Method | Path | Handler | Permission |
|---|---|---|---|
| GET | `/sports-dashboard` | `@clubLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/sport/{id}` | `@sportLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/facility/{id}` | `@facilityLevel` | `sports_dashboard.view` |
| GET | `/sports-dashboard/export` | `@export` | `sports_dashboard.export` |
## Metrics (`DashboardMetricsService`)
- `getDateRange($period, $from, $to)` — presets: day/week/month/year/3year/5year/custom.
- `getRevenue($start,$end,$facilityId,$disciplineId)` — sums `payments.amount` where
`payment_type IN ('activity_booking','pool_booking','sports_subscription')` and `is_voided=0`.
- `getPlayerDemographics($disciplineId=null)` — counts `players` by `player_type` (member/non_member).
- `getDisciplineBreakdown($start,$end)` — per-discipline revenue + `activity_subscriptions`
player count, from **`sport_disciplines`** (was a nonexistent `disciplines` table; see Risk Areas).
- `getFacilityUtilization($start,$end,$facilityId=null)` — booked `reservations` slots vs.
`facility_time_slots` × days-in-range, as a utilization %.
- `getPreviousPeriodComparison(...)` — current vs. prior-period revenue % change.
- `getCoachesForDiscipline($disciplineId,$start,$end)``coaches` joined to `facility_zone_schedules`.
- `getPopularTimeSlots($facilityId,$start,$end)` — top 10 booking hours from `reservations`.
## Views
- **club_dashboard.php** — KPI cards (revenue + prior-period %, active players, member/non-member
ratio, avg facility utilization); discipline table with drill-down; facility utilization table.
- **sport_dashboard.php** — KPI cards (revenue, registered players, coach count, session count); coach
performance table; linked academies table.
- **facility_dashboard.php** — KPI cards (revenue, bookings, utilization); popular time-slots table.
## Cross-Module Integration
- Uses `Shared\Services\PdfExportService::renderToPdf()` for export.
- Includes shared `Shared.Views._partials.time_filter` partial.
- No module calls into this controller/service — it is a pure downstream reporting consumer.
## Risk Areas / Technical Debt
1. ~~**Likely-broken query**~~ — **RESOLVED**: all three references to a table literally named
`disciplines` (`DashboardMetricsService::getDisciplineBreakdown()`,
`SportsDashboardController::sportLevel()` and `::export()`) now target **`sport_disciplines`**, which is
the FK target of `activity_subscriptions.discipline_id` and `academies.discipline_id`. Columns used
(`id`, `name_ar`, `name_en`, `is_archived`) were verified against `Phase_17_001` and all exist.
2. **Still-suspect predicate in `getDisciplineBreakdown()`** (NOT changed — outside the scope of the fix
pass, flag for follow-up): it filters `activity_subscriptions asub ON ... AND asub.status = 'active'`,
but that column's value set is `pending`/`paid`/`overdue`/`exempted`/`revoked``'active'` is not among
them. `player_count` will therefore read 0 for every discipline even now that the table name is correct.
Decide what "active player" should mean here (most likely `status IN ('pending','paid')`) before
trusting this metric.
3. **Schema generation mismatch**: `facilities`, `players`, `coaches`, `reservations`,
`activity_subscriptions`, `facility_zone_schedules`, `facility_time_slots` are real tables (created by
`Phase_17_002`, `Phase_19_001`, `Phase_43_001`, `Phase_20_001`, `Phase_22_001`, `Phase_54_003`,
`Phase_17_003`) — but they belong to the **older**, non-`sa_`-prefixed generation of the sports/activity
schema. `SportsActivity` (the actively developed module, `Phase_70+`) never appears in this dashboard's
queries at all.
4. **Overlapping/parallel dashboards**: `FacilityDashboards` module (`FacilityDashboardController`) is a
separate, similar per-facility dashboard also querying plain (non-`sa_`) `facilities`. It's unclear
which dashboard staff actually use — needs a product decision on which is authoritative, and whether
this module should be migrated to `sa_*` tables or retired.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment