Commit 39382feb authored by Mahmoud Aglan's avatar Mahmoud Aglan

Promote special membership types to independent primary types

Membership types (seasonal, sports, honorary, foreign) are now selectable
at creation time instead of being post-creation conversions from working.
Each type has its own fee logic, billing, activation path, and subscription rules.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 0dd4abfc
...@@ -74,8 +74,8 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -74,8 +74,8 @@ EventBus::listen('payment_request.completed', function (array $data) {
if ($memberId <= 0 || $paymentId <= 0) return; if ($memberId <= 0 || $paymentId <= 0) return;
// Membership activation (membership_fee / down_payment) // Membership activation (membership_fee / down_payment / type-specific fees)
if (in_array($paymentType, ['membership_fee', 'down_payment'], true)) { if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee'], true)) {
$requestData = []; $requestData = [];
if ($paymentType === 'down_payment' && $requestId > 0) { if ($paymentType === 'down_payment' && $requestId > 0) {
$request = $db->selectOne("SELECT notes FROM payment_requests WHERE id = ?", [$requestId]); $request = $db->selectOne("SELECT notes FROM payment_requests WHERE id = ?", [$requestId]);
...@@ -128,6 +128,13 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -128,6 +128,13 @@ EventBus::listen('payment_request.completed', function (array $data) {
'status' => 'active', 'status' => 'active',
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$entityId]); ], '`id` = ?', [$entityId]);
$seasonalMember = $db->selectOne("SELECT membership_type, status FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if ($seasonalMember && ($seasonalMember['membership_type'] ?? 'working') === 'seasonal' && $seasonalMember['status'] !== 'active') {
\App\Modules\Members\Services\MembershipPaymentGuard::activateMember($memberId, $paymentId);
EventBus::dispatch('member.activated', ['member_id' => $memberId]);
}
EventBus::dispatch('seasonal.fee_paid', ['seasonal_id' => $entityId, 'member_id' => $memberId, 'payment_id' => $paymentId]); EventBus::dispatch('seasonal.fee_paid', ['seasonal_id' => $entityId, 'member_id' => $memberId, 'payment_id' => $paymentId]);
} }
......
...@@ -15,6 +15,7 @@ use App\Modules\Rules\Services\RuleEngine; ...@@ -15,6 +15,7 @@ use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Forms\Services\FormBridge; use App\Modules\Forms\Services\FormBridge;
use App\Modules\Cashier\Services\PaymentRequestService; use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService;
class ChildController extends Controller class ChildController extends Controller
...@@ -44,6 +45,10 @@ class ChildController extends Controller ...@@ -44,6 +45,10 @@ class ChildController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
...@@ -65,6 +70,10 @@ class ChildController extends Controller ...@@ -65,6 +70,10 @@ class ChildController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أبناء بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
......
...@@ -368,7 +368,7 @@ class DeathController extends Controller ...@@ -368,7 +368,7 @@ class DeathController extends Controller
'gender' => $formData['gender'] ?? $spouse['gender'], 'gender' => $formData['gender'] ?? $spouse['gender'],
'nationality' => $formData['nationality'] ?? $spouse['nationality'] ?? 'مصري', 'nationality' => $formData['nationality'] ?? $spouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'], 'branch_id' => (int) $member['branch_id'],
'membership_type' => 'working', 'membership_type' => $member['membership_type'] ?? 'working',
'member_category' => 'working_member', 'member_category' => 'working_member',
'status' => 'active', 'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'), 'activated_at' => date('Y-m-d H:i:s'),
...@@ -425,7 +425,7 @@ class DeathController extends Controller ...@@ -425,7 +425,7 @@ class DeathController extends Controller
'gender' => $secSpouse['gender'], 'gender' => $secSpouse['gender'],
'nationality' => $secSpouse['nationality'] ?? 'مصري', 'nationality' => $secSpouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'], 'branch_id' => (int) $member['branch_id'],
'membership_type' => 'working', 'membership_type' => $member['membership_type'] ?? 'working',
'member_category' => 'working_member', 'member_category' => 'working_member',
'status' => 'active', 'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'), 'activated_at' => date('Y-m-d H:i:s'),
......
...@@ -277,7 +277,7 @@ class DivorceController extends Controller ...@@ -277,7 +277,7 @@ class DivorceController extends Controller
'gender' => $spouse['gender'], 'gender' => $spouse['gender'],
'nationality' => $spouse['nationality'] ?? 'مصري', 'nationality' => $spouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'], 'branch_id' => (int) $member['branch_id'],
'membership_type' => 'working', 'membership_type' => $member['membership_type'] ?? 'working',
'member_category' => 'working_member', 'member_category' => 'working_member',
'status' => 'active', 'status' => 'active',
'qualification_id' => $case['spouse_qualification_id'] ?? $spouse['qualification_id'] ?? $member['qualification_id'], 'qualification_id' => $case['spouse_qualification_id'] ?? $spouse['qualification_id'] ?? $member['qualification_id'],
......
...@@ -25,6 +25,9 @@ class ForeignController extends Controller ...@@ -25,6 +25,9 @@ class ForeignController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'foreign') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً أجنبياً');
}
$countries = $db->select("SELECT id, name_ar, nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"); $countries = $db->select("SELECT id, name_ar, nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar");
...@@ -50,6 +53,9 @@ class ForeignController extends Controller ...@@ -50,6 +53,9 @@ class ForeignController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'foreign') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً أجنبياً');
}
$data = $request->all(); $data = $request->all();
unset($data['_csrf_token']); unset($data['_csrf_token']);
...@@ -107,7 +113,7 @@ class ForeignController extends Controller ...@@ -107,7 +113,7 @@ class ForeignController extends Controller
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
$db->update('members', ['membership_type' => 'foreign', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $memberId]); // membership_type already set at creation — no update needed
// Process payment through central PaymentService (use EGP amount if available, otherwise USD) // Process payment through central PaymentService (use EGP amount if available, otherwise USD)
$chargeAmount = $feeEgp ?? $feeUsd; $chargeAmount = $feeEgp ?? $feeUsd;
......
...@@ -24,6 +24,9 @@ class HonoraryController extends Controller ...@@ -24,6 +24,9 @@ class HonoraryController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'honorary') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً شرفياً');
}
$durationData = RuleEngine::get('HONORARY_DURATION_YEARS'); $durationData = RuleEngine::get('HONORARY_DURATION_YEARS');
$years = $durationData['years'] ?? 1; $years = $durationData['years'] ?? 1;
...@@ -36,6 +39,9 @@ class HonoraryController extends Controller ...@@ -36,6 +39,9 @@ class HonoraryController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'honorary') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً شرفياً');
}
$data = $request->all(); $data = $request->all();
unset($data['_csrf_token']); unset($data['_csrf_token']);
...@@ -64,8 +70,17 @@ class HonoraryController extends Controller ...@@ -64,8 +70,17 @@ class HonoraryController extends Controller
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
]); ]);
// Update member type // membership_type already set at creation — activate honorary directly (no payment needed)
$db->update('members', ['membership_type' => 'honorary', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $memberId]); if ($member['status'] !== 'active') {
$db->update('members', [
'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $memberId]);
if (!$member['membership_number']) {
\App\Modules\Members\Services\MemberNumberGenerator::assign((int) $memberId);
}
}
EventBus::dispatch('honorary.created', ['member_id' => (int) $memberId, 'honorary_id' => (int) $honorary->id]); EventBus::dispatch('honorary.created', ['member_id' => (int) $memberId, 'honorary_id' => (int) $honorary->id]);
......
...@@ -21,6 +21,7 @@ use App\Modules\Workflow\Services\WorkflowEngine; ...@@ -21,6 +21,7 @@ use App\Modules\Workflow\Services\WorkflowEngine;
use App\Modules\Forms\Services\FormBridge; use App\Modules\Forms\Services\FormBridge;
use App\Core\Logger; use App\Core\Logger;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService;
class MemberController extends Controller class MemberController extends Controller
{ {
...@@ -73,6 +74,7 @@ class MemberController extends Controller ...@@ -73,6 +74,7 @@ class MemberController extends Controller
'nextFormNumber' => MemberNumberGenerator::next(), 'nextFormNumber' => MemberNumberGenerator::next(),
'formFee' => MemberNumberGenerator::getFormFee(), 'formFee' => MemberNumberGenerator::getFormFee(),
'needsStartNumber' => (MemberNumberGenerator::next() === null), 'needsStartNumber' => (MemberNumberGenerator::next() === null),
'membershipTypes' => MembershipRulesService::getCreatableMembershipTypes(),
]); ]);
} }
...@@ -86,6 +88,11 @@ class MemberController extends Controller ...@@ -86,6 +88,11 @@ class MemberController extends Controller
$nationalId = trim((string) $request->post('national_id', '')); $nationalId = trim((string) $request->post('national_id', ''));
$phoneMobile = trim((string) $request->post('phone_mobile', '')); $phoneMobile = trim((string) $request->post('phone_mobile', ''));
$branchId = (int) $request->post('branch_id', 0); $branchId = (int) $request->post('branch_id', 0);
$membershipType = trim((string) $request->post('membership_type', 'working'));
$validTypes = array_keys(MembershipRulesService::getCreatableMembershipTypes());
if (!in_array($membershipType, $validTypes, true)) {
$membershipType = 'working';
}
$errors = []; $errors = [];
if ($fullNameAr === '' || mb_strlen($fullNameAr) < 5) $errors[] = 'الاسم بالعربي مطلوب (5 أحرف على الأقل)'; if ($fullNameAr === '' || mb_strlen($fullNameAr) < 5) $errors[] = 'الاسم بالعربي مطلوب (5 أحرف على الأقل)';
if ($phoneMobile === '' || !preg_match('/^01[0125]\d{8}$/', $phoneMobile)) $errors[] = 'رقم المحمول غير صالح'; if ($phoneMobile === '' || !preg_match('/^01[0125]\d{8}$/', $phoneMobile)) $errors[] = 'رقم المحمول غير صالح';
...@@ -108,9 +115,10 @@ class MemberController extends Controller ...@@ -108,9 +115,10 @@ class MemberController extends Controller
if ($gender !== null && !in_array($gender, ['male', 'female'], true)) { if ($gender !== null && !in_array($gender, ['male', 'female'], true)) {
$errors[] = 'النوع يجب أن يكون ذكر أو أنثى'; $errors[] = 'النوع يجب أن يكون ذكر أو أنثى';
} }
$workingMinAge = (int) (RuleEngine::getValue('WORKING_MEMBER_MIN_AGE', 'value') ?? 21); $typeMinAge = MembershipRulesService::getMinAgeForType($membershipType);
if ($ageYears !== null && $ageYears < $workingMinAge) { if ($typeMinAge > 0 && $ageYears !== null && $ageYears < $typeMinAge) {
$errors[] = 'الحد الأدنى لسن العضوية العاملة ' . $workingMinAge . ' سنة (السن الحالي: ' . $ageYears . ')'; $typeLabel = MembershipRulesService::getMembershipTypeLabel($membershipType);
$errors[] = 'الحد الأدنى لسن ' . $typeLabel . ' ' . $typeMinAge . ' سنة (السن الحالي: ' . $ageYears . ')';
} }
$photoFile = $_FILES['photo'] ?? []; $photoFile = $_FILES['photo'] ?? [];
...@@ -129,7 +137,10 @@ class MemberController extends Controller ...@@ -129,7 +137,10 @@ class MemberController extends Controller
if (!$formNumber) return $this->redirect('/members/create')->withError('يجب تحديد رقم بداية الاستمارات'); if (!$formNumber) return $this->redirect('/members/create')->withError('يجب تحديد رقم بداية الاستمارات');
$nationality = ($idType === 'passport') ? 'أجنبي' : 'مصري'; $nationality = ($idType === 'passport') ? 'أجنبي' : 'مصري';
$member = Member::create(['full_name_ar' => $fullNameAr, 'national_id' => $nationalId ?: null, 'passport_number' => $request->post('passport_number') ?: null, 'id_type' => $idType, 'date_of_birth' => $dob, 'age_years' => $ageYears, 'age_months' => $ageMonths, 'gender' => $gender, 'governorate_code' => $govCode, 'phone_mobile' => $phoneMobile, 'branch_id' => $branchId, 'nationality' => $nationality, 'form_number' => (string) $formNumber, 'form_date' => date('Y-m-d'), 'status' => 'potential', 'membership_type' => 'working', 'member_category' => 'working_member']); if ($membershipType === 'foreign') {
$nationality = 'أجنبي';
}
$member = Member::create(['full_name_ar' => $fullNameAr, 'national_id' => $nationalId ?: null, 'passport_number' => $request->post('passport_number') ?: null, 'id_type' => $idType, 'date_of_birth' => $dob, 'age_years' => $ageYears, 'age_months' => $ageMonths, 'gender' => $gender, 'governorate_code' => $govCode, 'phone_mobile' => $phoneMobile, 'branch_id' => $branchId, 'nationality' => $nationality, 'form_number' => (string) $formNumber, 'form_date' => date('Y-m-d'), 'status' => 'potential', 'membership_type' => $membershipType, 'member_category' => $membershipType . '_member']);
$photoResult = PhotoUploadService::upload($photoFile, 'members', (int) $member->id); $photoResult = PhotoUploadService::upload($photoFile, 'members', (int) $member->id);
if ($photoResult) { if ($photoResult) {
$member->update(['photo_path' => $photoResult['path']]); $member->update(['photo_path' => $photoResult['path']]);
...@@ -144,7 +155,15 @@ class MemberController extends Controller ...@@ -144,7 +155,15 @@ class MemberController extends Controller
} }
} }
EventBus::dispatch('member.created', ['member_id' => (int) $member->id, 'form_number' => (string) $formNumber]); EventBus::dispatch('member.created', ['member_id' => (int) $member->id, 'form_number' => (string) $formNumber]);
return $this->redirect('/members/' . $member->id)->withSuccess('تم تسجيل العضو — استمارة رقم: ' . $formNumber);
$typeRedirects = [
'foreign' => '/members/' . $member->id . '/foreign/create',
'honorary' => '/members/' . $member->id . '/honorary/create',
'sports' => '/members/' . $member->id . '/sports/create',
'seasonal' => '/members/' . $member->id . '/seasonal/create',
];
$redirectUrl = $typeRedirects[$membershipType] ?? '/members/' . $member->id;
return $this->redirect($redirectUrl)->withSuccess('تم تسجيل العضو — استمارة رقم: ' . $formNumber);
} }
public function show(Request $request, string $id): Response public function show(Request $request, string $id): Response
......
...@@ -74,7 +74,7 @@ final class MembershipPaymentGuard ...@@ -74,7 +74,7 @@ final class MembershipPaymentGuard
} }
$otherValidPayment = $db->selectOne( $otherValidPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment') AND is_voided = 0 AND id != ? LIMIT 1", "SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment','foreign_membership_fee','sports_membership_fee','seasonal_fee') AND is_voided = 0 AND id != ? LIMIT 1",
[$memberId, $voidedPaymentId] [$memberId, $voidedPaymentId]
); );
...@@ -228,7 +228,7 @@ final class MembershipPaymentGuard ...@@ -228,7 +228,7 @@ final class MembershipPaymentGuard
"SELECT payment_type FROM payments WHERE id = ? AND is_voided = 0", "SELECT payment_type FROM payments WHERE id = ? AND is_voided = 0",
[(int) $entity['activated_by_payment_id']] [(int) $entity['activated_by_payment_id']]
); );
return $payment && in_array($payment['payment_type'], ['membership_fee', 'down_payment'], true); return $payment && in_array($payment['payment_type'], ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee', 'seasonal_fee'], true);
} }
/** /**
...@@ -248,8 +248,12 @@ final class MembershipPaymentGuard ...@@ -248,8 +248,12 @@ final class MembershipPaymentGuard
return ['success' => false, 'error' => 'العضو غير موجود']; return ['success' => false, 'error' => 'العضو غير موجود'];
} }
if (($member['membership_type'] ?? 'working') === 'honorary') {
return ['success' => true, 'changes' => []];
}
$hasValidPayment = $db->selectOne( $hasValidPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment') AND is_voided = 0 LIMIT 1", "SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment','foreign_membership_fee','sports_membership_fee','seasonal_fee') AND is_voided = 0 LIMIT 1",
[$memberId] [$memberId]
); );
......
...@@ -67,6 +67,45 @@ final class MembershipRulesService ...@@ -67,6 +67,45 @@ final class MembershipRulesService
]; ];
} }
// ══════════════════════════════════════════════════════════════════════
// Creatable Membership Types (أنواع العضويات الرئيسية عند الإنشاء)
// ══════════════════════════════════════════════════════════════════════
public static function getCreatableMembershipTypes(): array
{
return [
'working' => ['name_ar' => 'عضو عامل', 'min_age' => 21, 'allows_dependents' => true, 'requires_qualification' => true],
'seasonal' => ['name_ar' => 'عضو موسمي', 'min_age' => 0, 'allows_dependents' => false, 'requires_qualification' => false],
'sports' => ['name_ar' => 'عضو رياضي', 'min_age' => 0, 'allows_dependents' => false, 'requires_qualification' => false],
'honorary' => ['name_ar' => 'عضو شرفي', 'min_age' => 0, 'allows_dependents' => false, 'requires_qualification' => false],
'foreign' => ['name_ar' => 'عضو أجنبي', 'min_age' => 21, 'allows_dependents' => false, 'requires_qualification' => false],
];
}
public static function allowsDependents(string $membershipType): bool
{
$types = self::getCreatableMembershipTypes();
return $types[$membershipType]['allows_dependents'] ?? false;
}
public static function getMinAgeForType(string $membershipType): int
{
$types = self::getCreatableMembershipTypes();
return $types[$membershipType]['min_age'] ?? 0;
}
public static function requiresQualification(string $membershipType): bool
{
$types = self::getCreatableMembershipTypes();
return $types[$membershipType]['requires_qualification'] ?? false;
}
public static function getMembershipTypeLabel(string $membershipType): string
{
$types = self::getCreatableMembershipTypes();
return $types[$membershipType]['name_ar'] ?? 'عضو عامل';
}
public static function classifyMemberType(string $relationship, int $age): string public static function classifyMemberType(string $relationship, int $age): string
{ {
if ($relationship === 'self') { if ($relationship === 'self') {
......
...@@ -134,6 +134,17 @@ ...@@ -134,6 +134,17 @@
</select> </select>
</div> </div>
<!-- Membership Type -->
<div class="form-group">
<label class="form-label">نوع العضوية / Membership Type <span style="color:#DC2626;">*</span></label>
<select name="membership_type" id="membership_type" class="form-select" required style="font-size:16px;">
<?php foreach ($membershipTypes as $typeCode => $typeInfo): ?>
<option value="<?= e($typeCode) ?>" <?= old('membership_type', 'working') === $typeCode ? 'selected' : '' ?>><?= e($typeInfo['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small id="membership-type-hint" style="color:#6B7280;font-size:12px;margin-top:4px;display:block;"></small>
</div>
<!-- Toggle for non-Egyptian --> <!-- Toggle for non-Egyptian -->
<div class="form-group" style="grid-column:1/-1;"> <div class="form-group" style="grid-column:1/-1;">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#6B7280;"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#6B7280;">
...@@ -250,6 +261,27 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -250,6 +261,27 @@ document.addEventListener('DOMContentLoaded', function() {
govDisplay.textContent = ''; govDisplay.textContent = '';
} }
}); });
// Membership type switching logic
var typeSelect = document.getElementById('membership_type');
var typeHint = document.getElementById('membership-type-hint');
var typeHints = {
'working': 'العضو الأساسي — الحد الأدنى 21 سنة — يسمح بإضافة تابعين',
'seasonal': 'عضوية موسمية — بدون حد أدنى للسن — رسوم حسب المدة',
'sports': 'عضوية رياضية — لاعب مسجل باتحاد لعبة',
'honorary': 'عضوية شرفية — بقرار مجلس أمناء — بدون رسوم',
'foreign': 'عضو أجنبي — الحد الأدنى 21 سنة — جواز سفر مطلوب'
};
function updateTypeHint() {
var val = typeSelect.value;
typeHint.textContent = typeHints[val] || '';
if (val === 'foreign' && !noNidToggle.checked) {
noNidToggle.checked = true;
noNidToggle.dispatchEvent(new Event('change'));
}
}
typeSelect.addEventListener('change', updateTypeHint);
updateTypeHint();
}); });
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -663,18 +663,35 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done'])); ...@@ -663,18 +663,35 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Special Memberships (active members only) --> <!-- Membership Type Badge -->
<?php if ($isActive): ?> <?php
$typeLabels = [
'working' => ['label' => 'عضوية عاملة', 'color' => '#0D7377', 'bg' => '#E6FFFA'],
'seasonal' => ['label' => 'عضوية موسمية', 'color' => '#D97706', 'bg' => '#FFF7ED'],
'sports' => ['label' => 'عضوية رياضية', 'color' => '#7C3AED', 'bg' => '#F5F3FF'],
'honorary' => ['label' => 'عضوية شرفية', 'color' => '#059669', 'bg' => '#ECFDF5'],
'foreign' => ['label' => 'عضوية أجنبية', 'color' => '#2563EB', 'bg' => '#EFF6FF'],
];
$mType = $member->membership_type ?? 'working';
$typeInfo = $typeLabels[$mType] ?? $typeLabels['working'];
?>
<div style="margin-bottom:15px;"> <div style="margin-bottom:15px;">
<div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:8px;text-transform:uppercase;">🏅 عضويات خاصة</div> <div style="font-size:12px;color:#6B7280;font-weight:600;margin-bottom:8px;">نوع العضوية</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <span style="display:inline-block;padding:6px 14px;border-radius:20px;font-size:13px;font-weight:700;color:<?= $typeInfo['color'] ?>;background:<?= $typeInfo['bg'] ?>;border:1px solid <?= $typeInfo['color'] ?>30;">
<a href="/members/<?= (int) $member->id ?>/seasonal/create" class="btn btn-sm btn-outline">☀️ عضوية موسمية</a> <?= e($typeInfo['label']) ?>
<a href="/members/<?= (int) $member->id ?>/sports/create" class="btn btn-sm btn-outline">🏆 عضوية رياضية</a> </span>
<a href="/members/<?= (int) $member->id ?>/honorary/create" class="btn btn-sm btn-outline">⭐ عضوية شرفية</a> <?php if ($mType !== 'working' && $isActive): ?>
<a href="/members/<?= (int) $member->id ?>/foreign/create" class="btn btn-sm btn-outline">🌍 عضوية أجنبية</a> <?php
</div> $detailRoutes = [
'seasonal' => '/members/' . (int) $member->id . '/seasonal/create',
'sports' => '/members/' . (int) $member->id . '/sports/create',
'honorary' => '/members/' . (int) $member->id . '/honorary/create',
'foreign' => '/members/' . (int) $member->id . '/foreign/create',
];
?>
<a href="<?= $detailRoutes[$mType] ?? '#' ?>" class="btn btn-sm btn-outline" style="margin-right:8px;">تفاصيل النوع</a>
<?php endif; ?>
</div> </div>
<?php endif; ?>
<!-- Violations & Fines (active members only) --> <!-- Violations & Fines (active members only) -->
<?php if ($isActive && can('fine.impose')): ?> <?php if ($isActive && can('fine.impose')): ?>
...@@ -720,8 +737,9 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done'])); ...@@ -720,8 +737,9 @@ $hasIncomplete = !empty(array_filter($missingSteps, fn($s) => !$s['done']));
</div> </div>
<!-- ═══════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════ -->
<!-- FAMILY TREE — unified display of all dependants --> <!-- FAMILY TREE — unified display of all dependants (working members only) -->
<!-- ═══════════════════════════════════════════════ --> <!-- ═══════════════════════════════════════════════ -->
<?php if (($member->membership_type ?? 'working') === 'working'): ?>
<?php <?php
$totalDependants = count($spouses) + count($children) + count($temporaries ?? []); $totalDependants = count($spouses) + count($children) + count($temporaries ?? []);
$categoryLabels = [ $categoryLabels = [
...@@ -978,6 +996,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -978,6 +996,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; /* end working-only family tree */ ?>
<?php if (can('member.change_status')): ?> <?php if (can('member.change_status')): ?>
<!-- Status Change --> <!-- Status Change -->
......
...@@ -46,6 +46,9 @@ class SeasonalController extends Controller ...@@ -46,6 +46,9 @@ class SeasonalController extends Controller
if (!$member) { if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (($member['membership_type'] ?? 'working') !== 'seasonal') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً موسمياً');
}
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"); $branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
...@@ -72,6 +75,9 @@ class SeasonalController extends Controller ...@@ -72,6 +75,9 @@ class SeasonalController extends Controller
if (!$member) { if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (($member['membership_type'] ?? 'working') !== 'seasonal') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً موسمياً');
}
$data = $request->all(); $data = $request->all();
unset($data['_csrf_token']); unset($data['_csrf_token']);
......
...@@ -24,6 +24,9 @@ class SportsController extends Controller ...@@ -24,6 +24,9 @@ class SportsController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'sports') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً رياضياً');
}
$existing = SportsMember::getForMember((int) $memberId); $existing = SportsMember::getForMember((int) $memberId);
if ($existing) return $this->redirect("/members/{$memberId}")->withError('العضو لديه سجل رياضي بالفعل'); if ($existing) return $this->redirect("/members/{$memberId}")->withError('العضو لديه سجل رياضي بالفعل');
...@@ -40,6 +43,9 @@ class SportsController extends Controller ...@@ -40,6 +43,9 @@ class SportsController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [(int) $memberId]);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
if (($member['membership_type'] ?? 'working') !== 'sports') {
return $this->redirect("/members/{$memberId}")->withError('هذا العضو ليس عضواً رياضياً');
}
$data = $request->all(); $data = $request->all();
unset($data['_csrf_token']); unset($data['_csrf_token']);
......
...@@ -14,6 +14,7 @@ use App\Modules\Members\Services\NationalIdParser; ...@@ -14,6 +14,7 @@ use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Cashier\Services\PaymentRequestService; use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService;
class SpouseController extends Controller class SpouseController extends Controller
...@@ -43,6 +44,10 @@ class SpouseController extends Controller ...@@ -43,6 +44,10 @@ class SpouseController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
...@@ -79,6 +84,10 @@ class SpouseController extends Controller ...@@ -79,6 +84,10 @@ class SpouseController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة زوج/ة بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
......
...@@ -37,9 +37,26 @@ final class SubscriptionGenerator ...@@ -37,9 +37,26 @@ final class SubscriptionGenerator
$tempRate = bcmul($tempRate, $multiplier, 2); $tempRate = bcmul($tempRate, $multiplier, 2);
} }
// Get active members // Get exempt membership types from subscription_rate_overrides
$memberWhere = "m.status = 'active' AND m.is_archived = 0 AND m.membership_type NOT IN ('honorary')"; $exemptTypes = [];
$memberParams = []; try {
$overrides = $db->select(
"SELECT membership_type FROM subscription_rate_overrides WHERE financial_year = ? AND is_exempt = 1",
[$financialYear]
);
foreach ($overrides as $o) {
$exemptTypes[] = $o['membership_type'];
}
} catch (\Throwable $e) {
$exemptTypes = ['honorary', 'seasonal'];
}
if (empty($exemptTypes)) {
$exemptTypes = ['honorary', 'seasonal'];
}
$excludePlaceholders = implode(',', array_fill(0, count($exemptTypes), '?'));
$memberWhere = "m.status = 'active' AND m.is_archived = 0 AND m.membership_type NOT IN ({$excludePlaceholders})";
$memberParams = $exemptTypes;
if ($branchId) { if ($branchId) {
$memberWhere .= ' AND m.branch_id = ?'; $memberWhere .= ' AND m.branch_id = ?';
$memberParams[] = $branchId; $memberParams[] = $branchId;
......
...@@ -13,6 +13,7 @@ use App\Modules\Temporary\Services\TemporaryFeeCalculator; ...@@ -13,6 +13,7 @@ use App\Modules\Temporary\Services\TemporaryFeeCalculator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Cashier\Services\PaymentRequestService; use App\Modules\Cashier\Services\PaymentRequestService;
use App\Shared\Services\PhotoUploadService; use App\Shared\Services\PhotoUploadService;
use App\Modules\Members\Services\MembershipRulesService;
class TemporaryController extends Controller class TemporaryController extends Controller
...@@ -85,6 +86,10 @@ class TemporaryController extends Controller ...@@ -85,6 +86,10 @@ class TemporaryController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
...@@ -103,6 +108,10 @@ class TemporaryController extends Controller ...@@ -103,6 +108,10 @@ class TemporaryController extends Controller
return $this->redirect('/members')->withError('العضو غير موجود'); return $this->redirect('/members')->withError('العضو غير موجود');
} }
if (!MembershipRulesService::allowsDependents($member['membership_type'] ?? 'working')) {
return $this->redirect("/members/{$memberId}")->withError('نوع العضوية لا يسمح بإضافة تابعين');
}
if (self::isLocked($member) && !self::isSuperAdmin()) { if (self::isLocked($member) && !self::isSuperAdmin()) {
return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام'); return $this->redirect("/members/{$memberId}")->withError('لا يمكن إضافة أعضاء مؤقتين بعد تفعيل العضوية — تواصل مع المشرف العام');
} }
......
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS subscription_rate_overrides (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
membership_type VARCHAR(50) NOT NULL,
financial_year VARCHAR(20) NOT NULL,
member_rate DECIMAL(15,2) NOT NULL DEFAULT '0.00',
spouse_rate DECIMAL(15,2) NOT NULL DEFAULT '0.00',
child_rate DECIMAL(15,2) NOT NULL DEFAULT '0.00',
temp_rate DECIMAL(15,2) NOT NULL DEFAULT '0.00',
dev_fee DECIMAL(15,2) NOT NULL DEFAULT '0.00',
is_exempt TINYINT(1) NOT NULL DEFAULT 0,
notes TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_sub_rate_type_year (membership_type, financial_year)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO subscription_rate_overrides (membership_type, financial_year, member_rate, spouse_rate, child_rate, temp_rate, dev_fee, is_exempt, notes) VALUES
('honorary', '2025/2026', 0.00, 0.00, 0.00, 0.00, 0.00, 1, 'العضوية الشرفية معفاة من الاشتراكات'),
('honorary', '2024/2025', 0.00, 0.00, 0.00, 0.00, 0.00, 1, 'العضوية الشرفية معفاة من الاشتراكات'),
('seasonal', '2025/2026', 0.00, 0.00, 0.00, 0.00, 0.00, 1, 'العضوية الموسمية لها رسومها الخاصة'),
('seasonal', '2024/2025', 0.00, 0.00, 0.00, 0.00, 0.00, 1, 'العضوية الموسمية لها رسومها الخاصة'),
('sports', '2025/2026', 492.00, 0.00, 0.00, 0.00, 35.00, 0, 'العضوية الرياضية — نفس معدل العامل بدون تابعين'),
('sports', '2024/2025', 410.00, 0.00, 0.00, 0.00, 35.00, 0, 'العضوية الرياضية — نفس معدل العامل بدون تابعين'),
('foreign', '2025/2026', 492.00, 492.00, 222.00, 222.00, 35.00, 0, 'العضوية الأجنبية — نفس معدل العامل'),
('foreign', '2024/2025', 410.00, 410.00, 185.00, 185.00, 35.00, 0, 'العضوية الأجنبية — نفس معدل العامل')
",
'down' => "DROP TABLE IF EXISTS subscription_rate_overrides",
];
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
// Sports members: update membership_type for members with active sports records
$db->query(
"UPDATE members m
INNER JOIN sports_members sm ON sm.member_id = m.id
SET m.membership_type = 'sports',
m.member_category = 'sports_member',
m.updated_at = NOW()
WHERE m.membership_type = 'working'
AND m.is_archived = 0
AND sm.status = 'active'",
[]
);
// Seasonal members: only migrate members who have ONLY seasonal membership
// (never had a working membership_fee payment — meaning they were created specifically as seasonal)
$db->query(
"UPDATE members m
INNER JOIN seasonal_memberships sm ON sm.member_id = m.id AND sm.person_type = 'member'
LEFT JOIN payments p ON p.member_id = m.id
AND p.payment_type = 'membership_fee'
AND p.is_voided = 0
SET m.membership_type = 'seasonal',
m.member_category = 'seasonal_member',
m.updated_at = NOW()
WHERE m.membership_type = 'working'
AND m.is_archived = 0
AND sm.status = 'active'
AND p.id IS NULL",
[]
);
// Foreign and Honorary are already correctly typed by their respective controllers
// No action needed for them
};
This diff is collapsed.
This diff is collapsed.
# AccessMatrix Module — Architecture Map
> **Last updated:** 2026-06-10
> **Status:** Living document — incrementally updated as new information is discovered
---
## 1. Purpose & Responsibilities
The AccessMatrix module provides a **visual permission management interface** for the ERP. It manages:
- Full matrix view of all roles vs. all registered permissions (checkbox grid)
- Role comparison (diff two roles to see unique/shared permissions)
- Permission toggle (grant/revoke individual permissions per role)
- Role cloning (duplicate a role's permissions into a new role)
- Permission health audit (orphan permissions, unprotected routes, dependency violations)
- CSV export of the complete matrix
It does **NOT** directly manage:
- Role CRUD (roles table is managed elsewhere, matrix just reads/modifies permissions)
- User-to-role assignment (handled by HR/Auth modules)
- Permission registration (each module registers its own permissions in bootstrap.php)
---
## 2. Directory & File Structure
```
app/Modules/AccessMatrix/
├── bootstrap.php # Permission + menu registration
├── Routes.php # 6 routes
├── Controllers/
│ └── AccessMatrixController.php # All actions (index, compare, toggle, export, clone, health)
├── Services/
│ ├── MatrixService.php # Build matrix, compare roles, clone roles, export CSV
│ └── PermissionDiscoveryService.php # Orphan detection, unprotected routes, dependency validation
└── Views/
├── index.php # Full matrix grid with toggle checkboxes + clone modal
├── compare.php # Side-by-side role comparison
└── health.php # Permission health dashboard
```
---
## 3. Database Schema (Production — Source of Truth)
### 3.1 `roles` Table (29 active rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| role_code | varchar(50) | NO | UNI | Unique role identifier |
| name_ar | varchar(200) | NO | | Arabic display name |
| name_en | varchar(200) | YES | | English display name |
| description_ar | varchar(500) | YES | | |
| description_en | varchar(500) | YES | | |
| is_system | tinyint(1) | NO | | Default: 0 |
| is_active | tinyint(1) | NO | | Default: 1 |
| parent_role_id | bigint unsigned | YES | MUL | FK to roles (self-ref hierarchy) |
| has_all_branches | tinyint(1) | NO | | Default: 0 |
| category | varchar(50) | YES | | Role grouping category |
| level | tinyint unsigned | NO | | Sort order / hierarchy level, default 0 |
| is_template | tinyint(1) | NO | | Default: 0 |
| created_at | timestamp | NO | | |
| updated_at | timestamp | NO | | On update cascade |
| created_by | bigint unsigned | YES | | FK to employees |
| updated_by | bigint unsigned | YES | | FK to employees |
### 3.2 `role_permissions` Table (656 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| role_id | bigint unsigned | NO | MUL | FK to roles |
| permission_key | varchar(100) | NO | MUL | Permission string (e.g. 'member.view') |
| granted_at | timestamp | NO | | When permission was granted |
| granted_by | bigint unsigned | YES | | FK to employees (who granted it) |
### 3.3 `permission_dependencies` Table (95 rows as of 2026-06-10)
| Column | Type | Nullable | Key | Notes |
|--------|------|----------|-----|-------|
| id | bigint unsigned | NO | PRI | auto_increment |
| permission_key | varchar(100) | NO | MUL | The permission that depends on another |
| requires_key | varchar(100) | NO | MUL | The required permission |
| is_auto_grant | tinyint(1) | NO | | Default: 1; if true, granting parent auto-grants child |
---
## 4. Routes
| Method | Path | Action | Middleware | Permission |
|--------|------|--------|------------|------------|
| GET | /access-matrix | index | auth | access_matrix.view |
| GET | /access-matrix/compare | compare | auth | access_matrix.view |
| POST | /access-matrix/toggle | toggle | auth, csrf | access_matrix.manage |
| GET | /access-matrix/export | export | auth | access_matrix.view |
| POST | /access-matrix/clone | clone | auth, csrf | access_matrix.manage |
| GET | /access-matrix/health | health | auth | access_matrix.view |
---
## 5. Core Business Flows
### 5.1 Matrix View (index)
1. `MatrixService::buildMatrix()` queries all active roles ordered by `level`
2. Reads all registered permissions from `PermissionRegistry::getAllGrouped()`
3. For each role, queries `role_permissions` to get assigned keys
4. Wildcard check: if a role has `*` permission_key, all checkboxes show as granted
5. Returns grid data: groups > permissions > roles (granted: bool)
### 5.2 Permission Toggle (AJAX)
1. Receives `role_id` + `permission_key`
2. Validates role exists
3. Checks if `role_permissions` row exists for that combination
4. If exists: DELETE (revoke); if not: INSERT (grant)
5. Returns JSON `{granted: bool}`
### 5.3 Role Clone
1. Validates source role exists and new code is unique
2. Creates new role with same properties (sets `is_system=0`, `is_template=0`, `is_active=1`)
3. Copies all `role_permissions` from source to new role
### 5.4 Health Audit
- **Orphan Permissions**: Registered in `PermissionRegistry` but not used in any route's permission slot
- **Unprotected Routes**: Routes that have no permission key in position [4]
- **Dependency Violations**: Roles that have permission X which requires permission Y, but Y is missing
- **Stats**: Total permissions, group count, distribution by group
---
## 6. Events Dispatched
None. This module does not dispatch any events.
---
## 7. Events Consumed
None. This module does not listen to any events.
---
## 8. Cross-Module Dependencies
### 8.1 AccessMatrix IMPORTS FROM:
| Module/Component | What it uses |
|-----------------|-------------|
| App\Core\Registries\PermissionRegistry | `getAllGrouped()`, `getAll()` — reads all registered permissions from all modules |
| App\Core\Registries\MenuRegistry | Menu registration (bootstrap) |
### 8.2 Other modules that IMPORT FROM AccessMatrix:
None. No other modules reference AccessMatrix services or controllers.
### 8.3 Database Dependencies (reads from other modules' data)
| Table | Owner Module | How it's used |
|-------|-------------|---------------|
| roles | Auth/Settings | Read roles, insert cloned roles |
| role_permissions | Auth/Settings | Read, insert, delete permissions |
| permission_dependencies | Auth/Settings | Read dependency rules for health validation |
| employees | HR | Read current employee for `granted_by` |
---
## 9. Permissions
| Key | Description |
|-----|-------------|
| access_matrix.view | View the matrix, compare roles, export CSV, view health |
| access_matrix.manage | Toggle permissions, clone roles |
---
## 10. Background Processes (Cron)
None.
---
## 11. High-Risk Areas
### 11.1 Permission Toggle (CRITICAL)
- Direct manipulation of `role_permissions` table — no audit trail beyond `granted_at`/`granted_by`
- No confirmation step — single AJAX call grants/revokes
- Wildcard `*` roles can't be modified via toggle (they already have everything)
- If system roles (`is_system=1`) are modified, could break expected default behavior
### 11.2 Role Clone
- Creates a new role immediately — no approval workflow
- If source role has `*` permission, the clone also gets all individual permission rows (not wildcard)
- No way to undo a clone (must manually delete the role)
### 11.3 PermissionDiscoveryService File Scanning
- `findUnprotectedRoutes()` and `findOrphanPermissions()` scan ALL module Routes.php files via `glob()`
- Uses `require` to load route files — any side effects in route files will execute
- Performance: re-reads all route files on every health page load (no caching)
### 11.4 Dependency Validation
- `permission_dependencies` table data must be maintained manually
- If dependencies are stale or missing, health audit won't catch real violations
- No enforcement — violations are reported but permissions can still be granted
---
## 12. Known Patterns & Gotchas
1. **No EventBus usage**: This module is purely CRUD on permission data — no events dispatched or consumed
2. **PermissionRegistry is read-only**: The matrix doesn't modify what permissions exist, only which roles have them
3. **Wildcard permission**: A role with `permission_key = '*'` in `role_permissions` is treated as having all permissions
4. **CSV export includes BOM**: Output starts with `\xEF\xBB\xBF` for proper Excel Arabic support
5. **Health page performance**: Scans all Routes.php files + validates all roles' dependencies on every load
6. **No soft delete on roles/role_permissions**: Revoked permissions are hard-deleted
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -110,14 +110,15 @@ app/Modules/Subscriptions/ ...@@ -110,14 +110,15 @@ app/Modules/Subscriptions/
``` ```
1. Triggered: Cron (July 1-7 auto) OR manual via /subscriptions/batch-generate 1. Triggered: Cron (July 1-7 auto) OR manual via /subscriptions/batch-generate
2. For each active member (status='active', not honorary): 2. For each active member (status='active', excluding exempt types from `subscription_rate_overrides`):
a. Skip if subscription already exists for this member + FY (person_type='member') — dedup guard a. Skip if subscription already exists for this member + FY (person_type='member') — dedup guard
b. Create member subscription: base_amount + dev_fee (35 EGP) b. Create member subscription: base_amount + dev_fee (35 EGP)
c. For each active spouse: skip if (member+FY+person_type='spouse'+person_id) already exists, then insert c. For each active spouse: skip if (member+FY+person_type='spouse'+person_id) already exists, then insert
d. For each active child: skip if (member+FY+person_type='child'+person_id) already exists, then insert d. For each active child: skip if (member+FY+person_type='child'+person_id) already exists, then insert
e. For each active temp member: skip if (member+FY+person_type='temporary'+person_id) already exists, then insert e. For each active temp member: skip if (member+FY+person_type='temporary'+person_id) already exists, then insert
3. Rates loaded from service_catalog (year-specific if available, fallback to generic) 3. Exempt types loaded dynamically from `subscription_rate_overrides` table (fallback: honorary, seasonal)
4. Year-specific discount/increase from SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule 4. Rates loaded from service_catalog (year-specific if available, fallback to generic)
5. Year-specific discount/increase from SUBSCRIPTION_YEAR_ADJUSTMENT_{year} rule
``` ```
**Fix applied 2026-06-10:** Steps c/d/e previously had no dedup guard (only the member row in step a was **Fix applied 2026-06-10:** Steps c/d/e previously had no dedup guard (only the member row in step a was
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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