Commit 2b105af3 authored by Mahmoud Aglan's avatar Mahmoud Aglan

koool

parent 1c230fd3
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
/**
* Handles automatic freezing of male children who reach age 25,
* subscription blocking checks, and carnet printing eligibility.
*/
final class AutoFreezeService
{
/**
* Process automatic freeze for male children aged 25+.
* Sets is_frozen = 1 with reason indicating they must convert to independent membership.
*
* @return array{frozen_count: int, processed: int}
*/
public static function processAutoFreeze(): array
{
$db = App::getInstance()->db();
$children = $db->select(
"SELECT id, date_of_birth FROM children
WHERE gender = 'male'
AND is_frozen = 0
AND is_archived = 0
AND status = 'active'"
);
$processed = count($children);
$frozenCount = 0;
$now = date('Y-m-d H:i:s');
$today = new \DateTimeImmutable('today');
foreach ($children as $child) {
$dob = new \DateTimeImmutable($child['date_of_birth']);
$age = (int) $dob->diff($today)->y;
if ($age >= 25) {
$db->update(
'children',
[
'is_frozen' => 1,
'frozen_at' => $now,
'frozen_reason' => 'بلوغ سن 25 عام - يجب التحويل لعضوية مستقلة',
],
'id = ?',
[$child['id']]
);
$frozenCount++;
}
}
return [
'frozen_count' => $frozenCount,
'processed' => $processed,
];
}
/**
* Check if a member has unpaid subscriptions for the current financial year.
* Blocks certain operations if annual subscription is not paid.
*
* @return array{blocked: bool, reason?: string, unpaid_amount?: string}
*/
public static function checkSubscriptionBlock(int $memberId): array
{
$db = App::getInstance()->db();
$financialYear = self::getCurrentFinancialYear();
$unpaid = $db->select(
"SELECT total_amount, paid_amount FROM subscriptions
WHERE member_id = ?
AND financial_year = ?
AND status IN ('pending', 'overdue')",
[$memberId, $financialYear]
);
if (!empty($unpaid)) {
$unpaidAmount = '0.00';
foreach ($unpaid as $row) {
$remaining = bcsub($row['total_amount'], $row['paid_amount'], 2);
$unpaidAmount = bcadd($unpaidAmount, $remaining, 2);
}
return [
'blocked' => true,
'reason' => 'لم يتم سداد الاشتراك السنوي',
'unpaid_amount' => $unpaidAmount,
];
}
return ['blocked' => false];
}
/**
* Check if a member is eligible to print their carnet (membership card).
* Blocked if subscription unpaid, member is suspended, or member is frozen.
*
* @return array{allowed: bool, reason?: string}
*/
public static function canPrintCarnet(int $memberId): array
{
$db = App::getInstance()->db();
// Check member status
$member = $db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if (!$member) {
return [
'allowed' => false,
'reason' => 'العضو غير موجود',
];
}
if ($member['status'] === 'suspended') {
return [
'allowed' => false,
'reason' => 'العضوية موقوفة',
];
}
if ($member['status'] === 'frozen') {
return [
'allowed' => false,
'reason' => 'العضوية مجمدة',
];
}
// Check subscription block
$subscriptionCheck = self::checkSubscriptionBlock($memberId);
if ($subscriptionCheck['blocked']) {
return [
'allowed' => false,
'reason' => $subscriptionCheck['reason'],
];
}
return ['allowed' => true];
}
/**
* Get current financial year string.
* FY runs July to June: if month >= 7, FY is "thisYear/thisYear+1", else "lastYear/thisYear".
*/
private static function getCurrentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
if ($month >= 7) {
return $year . '/' . ($year + 1);
}
return ($year - 1) . '/' . $year;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
final class MembershipRulesService
{
// ══════════════════════════════════════════════════════════════════════
// Member Type Classification (أنواع الأعضاء)
// ══════════════════════════════════════════════════════════════════════
public static function getMemberTypes(): array
{
return [
[
'code' => 'working',
'name_ar' => 'عضو عامل',
'name_en' => 'Working Member',
'min_age' => 21,
'description' => 'العضو الأساسي الذي لا يقل سنه عن 21 عام',
],
[
'code' => 'dependent',
'name_ar' => 'عضو تابع',
'name_en' => 'Dependent Member',
'min_age' => 0,
'description' => 'الأبناء والزوج/الزوجة أقل من 21 سنة',
],
[
'code' => 'temporary',
'name_ar' => 'عضو مؤقت',
'name_en' => 'Temporary Member',
'min_age' => 0,
'description' => 'عضو مضاف بدون حق الفصل أو العضوية المستقلة',
],
[
'code' => 'honorary',
'name_ar' => 'عضو شرفي',
'name_en' => 'Honorary Member',
'min_age' => 0,
'description' => 'عضوية لمدة سنة قابلة للتجديد بدون رسوم',
],
[
'code' => 'athletic',
'name_ar' => 'عضو رياضي',
'name_en' => 'Athletic Member',
'min_age' => 0,
'description' => 'لاعب مسجل بأحد الألعاب لمدة 8 سنوات',
],
[
'code' => 'foreign',
'name_ar' => 'عضو أجنبي',
'name_en' => 'Foreign Member',
'min_age' => 21,
'description' => 'غير حامل الجنسية المصرية — رسوم 10,000 دولار',
],
[
'code' => 'seasonal',
'name_ar' => 'عضو موسمي',
'name_en' => 'Seasonal Member',
'min_age' => 0,
'description' => 'عضوية موسمية مؤقتة',
],
];
}
public static function classifyMemberType(string $relationship, int $age): string
{
if ($relationship === 'self') {
return $age >= 21 ? 'working' : 'dependent';
}
if (in_array($relationship, ['spouse', 'husband', 'wife'])) {
return $age >= 21 ? 'working' : 'dependent';
}
if (in_array($relationship, ['child', 'son', 'daughter'])) {
return 'dependent';
}
return 'temporary';
}
public static function validateMinAge(string $memberType, int $age): array
{
if ($memberType === 'working' && $age < 21) {
return ['valid' => false, 'reason' => 'العضو العامل الأساسي لا يقل سنه عن 21 عام'];
}
return ['valid' => true];
}
// ══════════════════════════════════════════════════════════════════════
// Form Fees (رسوم الاستمارات)
// ══════════════════════════════════════════════════════════════════════
public static function getNewMembershipFormFee(): array
{
$override = RuleEngine::get('membership.new_form_fee');
$formAmount = ($override['form_amount'] ?? '500');
$martyrsStamp = ($override['martyrs_stamp'] ?? '5');
$total = bcadd($formAmount, $martyrsStamp, 2);
return [
'form_amount' => $formAmount,
'martyrs_stamp' => $martyrsStamp,
'total' => $total,
'description' => 'استمارة العضوية الجديدة 500 جنيه + 5 طابع شهداء',
];
}
public static function getAdditionFormFee(): array
{
$override = RuleEngine::get('membership.addition_form_fee');
$formFee = ($override['amount'] ?? '570');
$annualSub = self::getAnnualRates(self::currentFinancialYear())['member'] ?? '492';
$devFee = self::getAnnualRates(self::currentFinancialYear())['dev'] ?? '35';
$totalAnnual = bcadd($annualSub, $devFee, 2);
$total = bcadd($formFee, $totalAnnual, 2);
return [
'form_fee' => $formFee,
'annual_subscription' => $totalAnnual,
'total' => $total,
'description' => 'أي إضافة بعد إنشاء العضوية: 570 جنيه + الاشتراك السنوي',
];
}
public static function getIncludedInMembership(): array
{
return [
'spouse_count' => 1,
'children_under_18' => 3,
'description' => 'تشمل العضوية: زوج + زوجة + 3 أبناء تحت 18 عام',
];
}
// ══════════════════════════════════════════════════════════════════════
// Membership Pricing
// ══════════════════════════════════════════════════════════════════════
public static function getMembershipPrice(string $qualificationLevel, ?string $asOfDate = null): string
{
$asOfDate = $asOfDate ?? date('Y-m-d');
$cutoff = '2024-07-01';
$override = RuleEngine::get('membership.price.' . $qualificationLevel, null, $asOfDate);
if ($override !== null) {
return (string) ($override['amount'] ?? $override['value'] ?? '0');
}
if ($asOfDate < $cutoff) {
return match ($qualificationLevel) {
'high', 'عالى' => '114000',
'medium', 'متوسط' => '171000',
'none', 'بدون' => '228000',
default => '0',
};
}
return match ($qualificationLevel) {
'high', 'عالى' => '150000',
'medium', 'متوسط' => '225000',
'none', 'بدون' => '300000',
default => '0',
};
}
// ══════════════════════════════════════════════════════════════════════
// Children Fees (أبناء العضو العامل)
// ══════════════════════════════════════════════════════════════════════
public static function getChildAdditionFee(int $childAge, string $membershipValue): array
{
$override = RuleEngine::get('membership.child_fee.' . $childAge);
if ($override !== null && isset($override['percentage'])) {
$pct = (string) $override['percentage'];
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
'type' => $override['type'] ?? 'regular',
];
}
if ($childAge < 18) {
// 4th child under 18
$pct = '5';
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
'type' => 'regular',
];
}
if ($childAge === 18) {
$pct = '10';
} elseif ($childAge === 19) {
$pct = '15';
} elseif ($childAge === 20) {
$pct = '20';
} else {
// 21+: temporary until 25
$pct = '15';
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
'type' => 'temporary',
];
}
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
'type' => 'regular',
];
}
// ══════════════════════════════════════════════════════════════════════
// Acquired Member Children (أبناء العضو مكتسب العضوية بعد الانفصال)
// ══════════════════════════════════════════════════════════════════════
public static function getAcquiredMemberChildFee(int $childAge, string $membershipValue): array
{
$override = RuleEngine::get('membership.acquired_child_fee.' . $childAge);
if ($override !== null && isset($override['percentage'])) {
$pct = (string) $override['percentage'];
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
];
}
if ($childAge < 12) {
$pct = '15';
} elseif ($childAge < 16) {
$pct = '20';
} elseif ($childAge < 18) {
$pct = '25';
} else {
$pct = '30';
}
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
];
}
// ══════════════════════════════════════════════════════════════════════
// Spouse Fees (زوجات العضو العامل)
// ══════════════════════════════════════════════════════════════════════
public static function getSpouseAdditionFee(
int $spouseOrder,
string $membershipValue,
?string $marriageOrMembershipDate = null,
bool $isForeign = false
): array {
$override = RuleEngine::get('membership.spouse_fee.' . $spouseOrder);
if ($override !== null && isset($override['percentage'])) {
return $override;
}
if ($spouseOrder === 1 && !$isForeign) {
return [
'percentage' => '0',
'amount' => '0.00',
'annual_fee' => '0.00',
'years' => 0,
'total' => '0.00',
];
}
if ($isForeign && $spouseOrder === 1) {
$pct = '15';
$amount = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'percentage' => $pct,
'amount' => $amount,
'annual_fee' => '0.00',
'years' => 0,
'total' => $amount,
];
}
$config = match ($spouseOrder) {
2 => ['percentage' => '10', 'annual_fee' => '150'],
3 => ['percentage' => '20', 'annual_fee' => '200'],
default => ['percentage' => '30', 'annual_fee' => '300'],
};
$baseAmount = bcdiv(bcmul($membershipValue, $config['percentage'], 4), '100', 2);
$years = 0;
$annualTotal = '0.00';
if ($marriageOrMembershipDate !== null) {
$start = new \DateTime($marriageOrMembershipDate);
$now = new \DateTime();
$diff = $start->diff($now);
$years = $diff->y;
if ($diff->m > 0 || $diff->d > 0) {
$years++;
}
$annualTotal = bcmul((string) $years, $config['annual_fee'], 2);
}
$total = bcadd($baseAmount, $annualTotal, 2);
return [
'percentage' => $config['percentage'],
'amount' => $baseAmount,
'annual_fee' => $config['annual_fee'],
'years' => $years,
'total' => $total,
];
}
// ══════════════════════════════════════════════════════════════════════
// Acquired Member Spouse Fee
// ══════════════════════════════════════════════════════════════════════
public static function getAcquiredMemberSpouseFee(int $spouseOrder, string $membershipValue): array
{
$override = RuleEngine::get('membership.acquired_spouse_fee.' . $spouseOrder);
if ($override !== null && isset($override['percentage'])) {
$pct = (string) $override['percentage'];
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
];
}
$pct = match ($spouseOrder) {
1 => '50',
default => '75',
};
return [
'percentage' => $pct,
'amount' => bcdiv(bcmul($membershipValue, $pct, 4), '100', 2),
];
}
// ══════════════════════════════════════════════════════════════════════
// Separation/Transfer Fees (رسوم الفصل)
// ══════════════════════════════════════════════════════════════════════
public static function getSeparationFee(int $yearsSinceAddition, string $currentMembershipValue): array
{
$override = RuleEngine::get('membership.separation_fee');
$formFee = '570';
if ($override !== null && isset($override['tiers'])) {
$tiers = $override['tiers'];
} else {
$tiers = [
1 => '30',
2 => '20',
3 => '15',
4 => '10',
5 => '5',
];
}
if (isset($tiers[$yearsSinceAddition])) {
$pct = (string) $tiers[$yearsSinceAddition];
} elseif ($yearsSinceAddition >= 6) {
$pct = '2.5';
} else {
$pct = '30';
}
$separationAmount = bcdiv(bcmul($currentMembershipValue, $pct, 4), '100', 2);
$annualSubscription = self::getAnnualRates(self::currentFinancialYear())['member'] ?? '410';
return [
'percentage' => $pct,
'separation_amount' => $separationAmount,
'form_fee' => $formFee,
'annual_subscription' => $annualSubscription,
'total' => bcadd(bcadd($separationAmount, $formFee, 2), $annualSubscription, 2),
];
}
// ══════════════════════════════════════════════════════════════════════
// Transfer Eligibility
// ══════════════════════════════════════════════════════════════════════
public static function canChildSeparate(string $gender, int $age, ?bool $isEmployed = null, ?bool $isMarried = null): array
{
if ($gender === 'female') {
if ($isMarried === true) {
return ['eligible' => true, 'reason' => 'ابنة متزوجة — يحق لها الفصل'];
}
return ['eligible' => false, 'reason' => 'الابنة غير متزوجة — لا يحق لها الفصل'];
}
// Male
if ($age >= 25) {
return ['eligible' => true, 'reason' => 'ابن بلغ 25 سنة — يحق له الفصل'];
}
if ($isEmployed === true) {
return ['eligible' => true, 'reason' => 'ابن متخرج وموظف — يحق له الفصل'];
}
return ['eligible' => false, 'reason' => 'ابن لم يبلغ 25 سنة وغير موظف — لا يحق له الفصل'];
}
// ══════════════════════════════════════════════════════════════════════
// Divorce Transfer Rules
// ══════════════════════════════════════════════════════════════════════
public static function getDivorceTransferFee(
string $divorceScenario,
string $membershipValue,
?int $yearsSinceMembership = null
): array {
$annualSubscription = self::getAnnualRates(self::currentFinancialYear())['member'] ?? '410';
switch ($divorceScenario) {
case 'both_working_before_marriage':
return [
'type' => 'annual_subscription_only',
'amount' => $annualSubscription,
'membership_basis' => 'working_member',
'conditions' => self::getDivorceConditions($yearsSinceMembership),
];
case 'same_form':
$pct = '10';
$amount = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'type' => 'percentage',
'percentage' => $pct,
'amount' => $amount,
'membership_basis' => 'working_member',
'conditions' => self::getDivorceConditions($yearsSinceMembership),
];
case 'joined_after':
$pct = '50';
$amount = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'type' => 'percentage',
'percentage' => $pct,
'amount' => $amount,
'membership_basis' => 'acquired_member',
'conditions' => self::getDivorceConditions($yearsSinceMembership),
];
default:
return [
'type' => 'unknown',
'amount' => '0.00',
'conditions' => [],
];
}
}
private static function getDivorceConditions(?int $yearsSinceMembership): array
{
$conditions = [];
$conditions[] = [
'rule' => 'divorce_within_one_year',
'description' => 'يجب أن يكون الطلاق قد تم خلال عام واحد',
];
$conditions[] = [
'rule' => 'membership_min_5_years',
'description' => 'مضى على العضوية 5 سنوات على الأقل (يُعفى إذا وُجد أبناء)',
'met' => $yearsSinceMembership !== null ? $yearsSinceMembership >= 5 : null,
'waivable' => true,
'waiver_reason' => 'وجود أبناء',
];
return $conditions;
}
// ══════════════════════════════════════════════════════════════════════
// Freeze Rules
// ══════════════════════════════════════════════════════════════════════
public static function shouldFreezeMaleChild(int $age): bool
{
return $age >= 25;
}
public static function isFreezeActive(array $child): bool
{
if (($child['gender'] ?? '') !== 'male') {
return false;
}
$age = (int) ($child['age_years'] ?? 0);
if ($age < 25) {
return false;
}
$status = $child['status'] ?? $child['membership_status'] ?? '';
return in_array($status, ['frozen', 'مجمد'], true);
}
// ══════════════════════════════════════════════════════════════════════
// Death Transfer
// ══════════════════════════════════════════════════════════════════════
public static function getDeathTransferRules(): array
{
$annualSubscription = self::getAnnualRates(self::currentFinancialYear())['member'] ?? '410';
return [
'beneficiary' => 'spouse',
'retains_number' => true,
'form_fee' => '570',
'annual_subscription' => $annualSubscription,
'membership_fee' => '0.00',
'description' => 'تنتقل العضوية للزوج/الزوجة بنفس الرقم مع سداد رسوم الاستمارة والاشتراك السنوي فقط',
];
}
// ══════════════════════════════════════════════════════════════════════
// Waiver (التنازل)
// ══════════════════════════════════════════════════════════════════════
public static function getWaiverFee(string $membershipValue): array
{
$override = RuleEngine::get('membership.waiver_fee');
$pct = ($override !== null && isset($override['percentage']))
? (string) $override['percentage']
: '30';
$amount = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'percentage' => $pct,
'amount' => $amount,
];
}
public static function getWaiverConditions(): array
{
return [
[
'code' => 'board_approval',
'description' => 'موافقة مجلس الأمناء',
'required' => true,
],
[
'code' => 'dependants_limit',
'description' => 'لا يزيد عدد الأعضاء للعضو المتنازل له عن عدد الأعضاء التابعين للعضو المتنازل',
'required' => true,
],
[
'code' => 'extra_dependants_fee',
'description' => 'في حالة رغبة المتنازل له في إضافة أعضاء بالزيادة يلتزم بسداد الرسوم المحددة من مجلس الأمناء',
'required' => false,
],
[
'code' => 'annual_subscription_paid',
'description' => 'التجديد السنوي قبل إتمام التنازل',
'required' => true,
],
[
'code' => 'new_form_required',
'description' => 'استمارة عضوية جديدة للعضو المتنازل له',
'required' => true,
],
[
'code' => 'same_membership_number',
'description' => 'يصبح العضو الأساسي بنفس رقم العضوية المتنازل',
'required' => true,
],
[
'code' => 'archive_old_data',
'description' => 'يتم نقل جميع البيانات القديمة إلى الأرشيف للحفظ',
'required' => true,
],
];
}
public static function getWaiverProcess(): array
{
return [
'fee_percentage' => '30',
'retains_number' => true,
'requires_new_form' => true,
'archive_source_data' => true,
'steps' => [
'طلب تنازل مع موافقة مجلس الأمناء',
'سداد 30% من قيمة العضوية وقت التنازل',
'استمارة عضوية جديدة للمتنازل له',
'سداد التجديد السنوي',
'أرشفة بيانات العضو المتنازل',
'تسجيل المتنازل له بنفس رقم العضوية',
],
];
}
// ══════════════════════════════════════════════════════════════════════
// Temporary Members (العضو المؤقت)
// ══════════════════════════════════════════════════════════════════════
public static function getTemporaryMemberFee(string $membershipValue): string
{
$override = RuleEngine::get('membership.temporary_fee');
$pct = ($override !== null && isset($override['percentage']))
? (string) $override['percentage']
: '10';
return bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
}
public static function getTemporaryMemberCategories(): array
{
return [
[
'code' => 'special_needs_over_21',
'name_ar' => 'أبناء ذوي الاحتياجات الخاصة فوق 21 سنة',
'fee_applies' => true,
'can_separate' => false,
],
[
'code' => 'parents',
'name_ar' => 'والدا العضو العامل الأصلي',
'fee_applies' => true,
'can_separate' => false,
],
[
'code' => 'unmarried_unemployed_daughters',
'name_ar' => 'بنات العضو الغير متزوجات واللاتي لا يعملن بأجر',
'fee_applies' => true,
'can_separate' => false,
],
[
'code' => 'sisters_under_25',
'name_ar' => 'شقيقات العضو أقل من 25 سنة (غير متزوجات/مطلقات/أرامل، غير عاملات، مقيمات معه، وهو عائلهن الوحيد)',
'fee_applies' => true,
'can_separate' => false,
'max_age' => 25,
],
[
'code' => 'stepchildren_under_25',
'name_ar' => 'أبناء الزوج/الزوجة من غير أبناء العضو العامل (أقل من 25 سنة)',
'fee_applies' => true,
'can_separate' => false,
'max_age' => 25,
],
[
'code' => 'orphan_sponsored',
'name_ar' => 'الطفل اليتيم الذي تكفله أسرة العضو (قانون 12/1996) حتى 25 عام',
'fee_applies' => true,
'fee_amount' => 'غير محدد حالياً',
'can_separate' => false,
'max_age' => 25,
],
[
'code' => 'disabled_sibling',
'name_ar' => 'شقيق العضو المعاق (بشرط أن يكون العضو عائله الوحيد)',
'fee_applies' => true,
'fee_amount' => 'غير محدد حالياً',
'can_separate' => false,
],
[
'code' => 'nanny',
'name_ar' => 'المربية',
'fee_percentage' => '5',
'fee_applies' => true,
'can_separate' => false,
],
[
'code' => 'championship_exempt',
'name_ar' => 'حاصل على بطولات جمهورية (بدون رسوم إضافة)',
'fee_applies' => false,
'can_separate' => false,
],
];
}
public static function getCompanionFee(string $categoryCode, string $membershipValue): array
{
$categories = self::getTemporaryMemberCategories();
$category = null;
foreach ($categories as $cat) {
if ($cat['code'] === $categoryCode) {
$category = $cat;
break;
}
}
if (!$category) {
return ['error' => 'فئة غير معروفة', 'fee' => '0.00'];
}
if (!$category['fee_applies']) {
return ['fee' => '0.00', 'category' => $category['name_ar'], 'exempt' => true];
}
if (isset($category['fee_percentage'])) {
$pct = $category['fee_percentage'];
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return ['fee' => $fee, 'percentage' => $pct, 'category' => $category['name_ar']];
}
// Default: 10% for temporary members
$pct = '10';
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return ['fee' => $fee, 'percentage' => $pct, 'category' => $category['name_ar']];
}
// ══════════════════════════════════════════════════════════════════════
// Honorary Member (العضو الشرفي)
// ══════════════════════════════════════════════════════════════════════
public static function getHonoraryMemberRules(): array
{
return [
'duration_years' => 1,
'renewable' => true,
'fees' => '0.00',
'annual_subscription' => '0.00',
'requires_board_approval'=> true,
'conditions' => [
'قرار من مجلس الأمناء',
'نظير خدمات جليلة للدولة أو المنشأة الرياضية',
],
'description' => 'عضوية لمدة سنة واحدة قابلة للتجديد بدون رسوم أو اشتراكات',
];
}
// ══════════════════════════════════════════════════════════════════════
// Athletic Member (العضو الرياضي)
// ══════════════════════════════════════════════════════════════════════
public static function getAthleticMemberConversionRules(): array
{
$override = RuleEngine::get('membership.athletic_conversion');
return [
'conversion_percentage' => ($override['percentage'] ?? '50'),
'min_playing_years' => ($override['min_years'] ?? 8),
'conditions' => [
'مرور 8 سنوات على الأقل لاعباً بأحد الألعاب بالمدينة الرياضية',
'مسجل باتحاد اللعبة باسم المدينة الرياضية',
'تمثيل المدينة الرياضية في أعلى مستوى تنافسي طوال المدة',
],
'description' => 'رسوم تحويل 50% من قيمة العضوية الجديدة وقت تقديم الطلب',
];
}
public static function getAthleticConversionFee(string $membershipValue): array
{
$rules = self::getAthleticMemberConversionRules();
$pct = $rules['conversion_percentage'];
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'percentage' => $pct,
'fee' => $fee,
'conditions' => $rules['conditions'],
];
}
// ══════════════════════════════════════════════════════════════════════
// Foreign Member (العضو الأجنبي)
// ══════════════════════════════════════════════════════════════════════
public static function getForeignMemberRules(): array
{
$override = RuleEngine::get('membership.foreign_member');
return [
'fee_usd' => ($override['fee_usd'] ?? '10000'),
'currency' => 'USD',
'includes' => 'زوج + زوجة + 3 أبناء',
'has_full_rights' => true,
'can_separate' => true,
'can_transfer' => true,
'description' => 'الذي لا يحمل الجنسية المصرية — رسوم 10,000 دولار شاملة العائلة',
];
}
// ══════════════════════════════════════════════════════════════════════
// Annual Subscription Rates
// ══════════════════════════════════════════════════════════════════════
public static function getAnnualRates(string $financialYear): array
{
$override = RuleEngine::get('membership.annual_rates.' . $financialYear);
if ($override !== null) {
return $override;
}
return match ($financialYear) {
'2023/2024' => [
'member' => '410',
'spouse' => '410',
'child' => '185',
'temp' => '185',
'dev' => '35',
'note' => 'خصم 50% مطبق',
],
'2024/2025' => [
'member' => '410',
'spouse' => '410',
'child' => '185',
'temp' => '185',
'dev' => '35',
],
'2025/2026' => [
'member' => '492',
'spouse' => '492',
'child' => '222',
'temp' => '222',
'dev' => '35',
'note' => 'زيادة 20%',
],
default => [
'member' => '492',
'spouse' => '492',
'child' => '222',
'temp' => '222',
'dev' => '35',
],
};
}
// ══════════════════════════════════════════════════════════════════════
// Installment Rules
// ══════════════════════════════════════════════════════════════════════
public static function getInstallmentTerms(string $membershipValue, string $downPayment): array
{
$override = RuleEngine::get('membership.installment_terms') ?? [];
$minDownPct = !empty($override['min_down_percentage']) ? (string) $override['min_down_percentage'] : '25';
$maxMonths = !empty($override['max_months']) ? (int) $override['max_months'] : 30;
$annualInterest = !empty($override['annual_interest']) ? (string) $override['annual_interest'] : '22';
$cashGraceDays = !empty($override['cash_grace_days']) ? (int) $override['cash_grace_days'] : 30;
$minDownAmount = bcdiv(bcmul($membershipValue, $minDownPct, 4), '100', 2);
$downPaymentValid = bccomp($downPayment, $minDownAmount, 2) >= 0;
$remaining = bcsub($membershipValue, $downPayment, 2);
if (bccomp($remaining, '0', 2) < 0) {
$remaining = '0.00';
}
$monthlyInterestRate = bcdiv($annualInterest, '1200', 8);
$totalInterest = bcdiv(bcmul(bcmul($remaining, $annualInterest, 4), (string) $maxMonths, 4), '1200', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyInstallment = $maxMonths > 0
? bcdiv($totalWithInterest, (string) $maxMonths, 2)
: '0.00';
return [
'membership_value' => $membershipValue,
'min_down_percentage' => $minDownPct,
'min_down_amount' => $minDownAmount,
'down_payment' => $downPayment,
'down_payment_valid' => $downPaymentValid,
'remaining' => $remaining,
'max_months' => $maxMonths,
'annual_interest' => $annualInterest,
'monthly_interest_rate' => $monthlyInterestRate,
'total_interest' => $totalInterest,
'total_with_interest' => $totalWithInterest,
'monthly_installment' => $monthlyInstallment,
'cash_grace_days' => $cashGraceDays,
'cash_note' => 'السداد الكامل خلال ' . $cashGraceDays . ' يوم بدون فوائد',
];
}
// ══════════════════════════════════════════════════════════════════════
// Penalties & Violations (مخالفات الأعضاء ومساءلتهم)
// ══════════════════════════════════════════════════════════════════════
public static function getViolationPenalties(): array
{
return [
['code' => 'attention', 'name_ar' => 'لفت نظر', 'severity' => 1, 'financial' => false],
['code' => 'warning', 'name_ar' => 'إنذار', 'severity' => 2, 'financial' => false],
['code' => 'fine', 'name_ar' => 'غرامة', 'severity' => 3, 'financial' => true, 'min' => '1000', 'max' => '10000'],
['code' => 'suspension', 'name_ar' => 'إيقاف عن مزاولة النشاط', 'severity' => 4, 'financial' => false, 'max_duration_months' => 6],
['code' => 'ban', 'name_ar' => 'حرمان من دخول النادي', 'severity' => 5, 'financial' => false, 'max_duration_months' => 6],
['code' => 'expulsion', 'name_ar' => 'فصل العضو', 'severity' => 6, 'financial' => false, 'no_refund' => true],
];
}
public static function validateFineAmount(string $amount): array
{
$min = '1000';
$max = '10000';
$override = RuleEngine::get('membership.fine_limits');
if ($override) {
$min = (string) ($override['min'] ?? '1000');
$max = (string) ($override['max'] ?? '10000');
}
if (bccomp($amount, $min, 2) < 0) {
return ['valid' => false, 'reason' => 'الغرامة لا تقل عن ' . $min . ' جنيه'];
}
if (bccomp($amount, $max, 2) > 0) {
return ['valid' => false, 'reason' => 'الغرامة لا تزيد عن ' . $max . ' جنيه'];
}
return ['valid' => true];
}
public static function canAppeal(string $penaltyDate): bool
{
$deadline = (new \DateTime($penaltyDate))->modify('+15 days');
return new \DateTime() <= $deadline;
}
public static function getAppealDeadline(string $penaltyDate): string
{
return (new \DateTime($penaltyDate))->modify('+15 days')->format('Y-m-d');
}
public static function canRequestReview(string $expulsionDate): bool
{
$eligibleDate = (new \DateTime($expulsionDate))->modify('+6 months');
return new \DateTime() >= $eligibleDate;
}
public static function getReviewEligibleDate(string $expulsionDate): string
{
return (new \DateTime($expulsionDate))->modify('+6 months')->format('Y-m-d');
}
// ══════════════════════════════════════════════════════════════════════
// Fines & Penalties Accumulation (الغرامات)
// ══════════════════════════════════════════════════════════════════════
public static function getFineAccumulationRules(): array
{
return [
'max_accumulation_years' => 5,
'drop_after_max' => true,
'description' => 'تطبق غرامات وتستمر في التراكم لمدة 5 سنوات كحد أقصى — بعدها إسقاط العضوية',
];
}
// ══════════════════════════════════════════════════════════════════════
// Membership Termination & Drop (انتهاء وإسقاط العضوية)
// ══════════════════════════════════════════════════════════════════════
public static function getMembershipEndReasons(): array
{
return [
['code' => 'death', 'name_ar' => 'الوفاة', 'type' => 'termination'],
['code' => 'waiver', 'name_ar' => 'التنازل عن العضوية', 'type' => 'termination'],
['code' => 'board_decision', 'name_ar' => 'إنهاء العضوية بقرار مجلس أمناء', 'type' => 'termination'],
['code' => 'condition_lost', 'name_ar' => 'فقد شرط من شروط العضوية', 'type' => 'drop'],
['code' => 'expulsion', 'name_ar' => 'الفصل من العضوية طبقاً للائحة', 'type' => 'drop'],
['code' => 'installment_default', 'name_ar' => 'عدم الالتزام بسداد الأقساط المستحقة', 'type' => 'drop'],
['code' => 'unpaid_5_years', 'name_ar' => 'تأخر عن سداد الاشتراك السنوي 5 سنوات متتالية', 'type' => 'drop'],
];
}
public static function shouldDropMembership(int $unpaidYears, bool $hasUnpaidInstallments = false): bool
{
if ($hasUnpaidInstallments) {
return true;
}
return $unpaidYears >= 5;
}
public static function canReinstate(string $dropDate): array
{
$deadline = (new \DateTime($dropDate))->modify('+1 year');
$eligible = new \DateTime() <= $deadline;
return [
'eligible' => $eligible,
'deadline' => $deadline->format('Y-m-d'),
'requires' => [
'سداد جميع المبالغ المتأخرة',
'ما يحدده مجلس الأمناء من غرامات',
],
'board_approval' => true,
'description' => $eligible
? 'يجوز إعادة العضوية خلال سنة من تاريخ الإسقاط بعد السداد وموافقة المجلس'
: 'انتهت مهلة السنة — لا يمكن إعادة العضوية',
];
}
public static function getBlockNonPayingMemberRules(): array
{
return [
'block_entry' => true,
'block_carnet' => true,
'block_subscription' => true,
'description' => 'منع دخول الأعضاء الغير مسددين ومنع طباعة الكارنيه',
];
}
// ══════════════════════════════════════════════════════════════════════
// Group Discounts (المجمعة)
// ══════════════════════════════════════════════════════════════════════
public static function getGroupDiscount(int $membershipCount): array
{
$override = RuleEngine::get('membership.group_discount');
if ($override !== null && isset($override['tiers'])) {
$tiers = $override['tiers'];
} else {
$tiers = [
['min' => 5, 'max' => 10, 'percentage' => '3'],
['min' => 11, 'max' => 20, 'percentage' => '7'],
['min' => 21, 'max' => null, 'percentage' => '10'],
];
}
foreach ($tiers as $tier) {
$min = (int) $tier['min'];
$max = $tier['max'] !== null ? (int) $tier['max'] : PHP_INT_MAX;
if ($membershipCount >= $min && $membershipCount <= $max) {
return [
'eligible' => true,
'percentage' => $tier['percentage'],
'count' => $membershipCount,
];
}
}
return [
'eligible' => false,
'percentage' => '0',
'count' => $membershipCount,
];
}
// ══════════════════════════════════════════════════════════════════════
// Carnet Rules (طباعة الكارنيهات)
// ══════════════════════════════════════════════════════════════════════
public static function canPrintCarnet(int $memberId): array
{
$db = App::getInstance()->db();
$currentYear = self::currentFinancialYear();
// Check member status
$member = $db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if (!$member) {
return ['allowed' => false, 'reason' => 'العضو غير موجود'];
}
if (in_array($member['status'], ['suspended', 'frozen', 'dropped'])) {
return ['allowed' => false, 'reason' => 'العضوية غير فعالة (حالة: ' . $member['status'] . ')'];
}
// Check annual subscription paid
$paid = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'annual_subscription' AND financial_year = ? AND is_voided = 0 LIMIT 1",
[$memberId, $currentYear]
);
if (!$paid) {
return ['allowed' => false, 'reason' => 'لم يتم سداد الاشتراك السنوي للعام ' . $currentYear];
}
// Check outstanding installments
$unpaidInstallments = $db->selectOne(
"SELECT COUNT(*) as cnt FROM installment_payments WHERE member_id = ? AND status IN ('pending','overdue')",
[$memberId]
);
if ((int) ($unpaidInstallments['cnt'] ?? 0) > 0) {
return ['allowed' => false, 'reason' => 'يوجد أقساط مستحقة غير مسددة'];
}
return ['allowed' => true, 'reason' => 'مسموح بالطباعة'];
}
public static function getLostCarnetFee(): array
{
$override = RuleEngine::get('membership.lost_carnet_fee');
return [
'fee' => ($override['amount'] ?? '200'),
'description' => 'رسوم بدل فاقد كارنيه',
];
}
public static function getCarnetPrintRules(): array
{
return [
'requires_annual_paid' => true,
'requires_no_installment_debt' => true,
'requires_active_status' => true,
'log_employee_and_date' => true,
'description' => 'لا يُسمح بطباعة الكارنيهات إلا بعد سداد الاشتراك السنوي — يظهر اسم الموظف والتاريخ',
];
}
// ══════════════════════════════════════════════════════════════════════
// Numbering (نظام ترقيم العضويات)
// ══════════════════════════════════════════════════════════════════════
public static function getNewNumberingFormat(): array
{
$override = RuleEngine::get('NEW_NUMBERING_START_DATE');
return [
'format' => '2/XXXX',
'start_from' => ($override['start_number'] ?? '1001'),
'prefix' => ($override['prefix'] ?? '2'),
'effective_date' => ($override['effective_date'] ?? '2026-07-01'),
'applies_to' => 'new_memberships_only',
'description' => 'نظام ترقيم جديد متسلسل يبدأ من 2/1001 — يطبق على العضويات الجديدة فقط حتى 1/7/2026 ثم يعمم',
];
}
public static function generateNextMembershipNumber(): string
{
$db = App::getInstance()->db();
$config = self::getNewNumberingFormat();
$prefix = $config['prefix'];
$last = $db->selectOne(
"SELECT membership_number FROM members WHERE membership_number LIKE ? ORDER BY id DESC LIMIT 1",
[$prefix . '/%']
);
if ($last) {
$parts = explode('/', $last['membership_number']);
$nextNum = (int) ($parts[1] ?? 1000) + 1;
} else {
$nextNum = (int) $config['start_from'];
}
return $prefix . '/' . $nextNum;
}
// ══════════════════════════════════════════════════════════════════════
// Transfer/Separation Archive Rules (الأرشيف)
// ══════════════════════════════════════════════════════════════════════
public static function getArchiveRules(): array
{
return [
'never_delete' => true,
'archive_on_transfer' => true,
'archive_on_waiver' => true,
'archive_on_death_transfer' => true,
'archive_on_separation' => true,
'track_old_membership_number' => true,
'track_new_membership_number' => true,
'log_employee' => true,
'log_action_type' => true,
'log_timestamp' => true,
'log_notes' => true,
'description' => 'أي حذف أو تحويل أو تعديل لا يُحذف نهائياً — يتم نقل البيانات القديمة إلى الأرشيف مع الاحتفاظ بالتسلسل',
];
}
// ══════════════════════════════════════════════════════════════════════
// Board Authority (سلطة مجلس الأمناء)
// ══════════════════════════════════════════════════════════════════════
public static function getBoardAuthorityRules(): array
{
return [
'can_adjust_subscription' => true,
'can_exempt_from_subscription' => true,
'can_adjust_membership_price' => true,
'can_offer_discounts' => true,
'can_set_installment_terms' => true,
'can_reinstate_dropped' => true,
'can_reduce_fine_on_review' => true,
'max_fine_on_reinstate' => '10000',
'reference_article' => 'المادة 114',
];
}
// ══════════════════════════════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════════════════════════════
private static function currentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
if ($month >= 7) {
return $year . '/' . ($year + 1);
}
return ($year - 1) . '/' . $year;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Members\Services;
use App\Core\App;
use App\Core\Logger;
/**
* Handles membership waiver (التنازل عن العضوية).
* A waiver transfers the membership from one person to another with board approval.
*/
final class WaiverService
{
private const WAIVER_FEE_PERCENTAGE = '30.00';
/**
* Calculate the waiver fee for a member based on their current membership value.
* Fee = 30% of current membership value.
*
* @return array{membership_value: string, percentage: string, waiver_fee: string, annual_subscription_required: bool}
*/
public static function calculateWaiverFee(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne(
"SELECT id, membership_value, qualification_id FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if (!$member) {
return [
'membership_value' => '0.00',
'percentage' => self::WAIVER_FEE_PERCENTAGE,
'waiver_fee' => '0.00',
'annual_subscription_required' => true,
];
}
$membershipValue = $member['membership_value'] ?? '0.00';
// If membership_value is zero or not set, try service_catalog based on qualification
if (bccomp($membershipValue, '0', 2) <= 0 && !empty($member['qualification_id'])) {
$qual = $db->selectOne(
"SELECT code FROM qualifications WHERE id = ?",
[(int) $member['qualification_id']]
);
if ($qual && !empty($qual['code'])) {
$serviceCode = 'SVC_MEMBERSHIP_' . strtoupper($qual['code']);
$catalogValue = \App\Modules\ServiceCatalog\Models\ServicePrice::getPrice($serviceCode);
if ($catalogValue !== null) {
$membershipValue = $catalogValue;
}
}
}
$waiverFee = bcdiv(bcmul($membershipValue, self::WAIVER_FEE_PERCENTAGE, 4), '100', 2);
return [
'membership_value' => $membershipValue,
'percentage' => self::WAIVER_FEE_PERCENTAGE,
'waiver_fee' => $waiverFee,
'annual_subscription_required' => true,
];
}
/**
* Get the list of conditions required for a membership waiver.
*
* @return array<int, string>
*/
public static function getWaiverConditions(): array
{
return [
'موافقة مجلس الإدارة مطلوبة',
'لا يجوز أن يتجاوز عدد المرافقين للمتنازل إليه عدد مرافقي المتنازل',
'يحتفظ بنفس رقم العضوية',
'يجب سداد الاشتراك السنوي قبل التنازل',
'يتم أرشفة بيانات العضو القديم (لا تُحذف)',
];
}
/**
* Process a waiver request: validate source member, count dependants,
* and create a transfer_request record.
*
* @param int $sourceMemberId ID of the member transferring their membership
* @param array $newMemberData Data for the new member receiving the membership
*
* @return array{transfer_request_id: int, fee: string, conditions: array<int, string>}
*
* @throws \RuntimeException If source member is not active or not found
*/
public static function processWaiver(int $sourceMemberId, array $newMemberData): array
{
$db = App::getInstance()->db();
// Validate source member exists and is active
$sourceMember = $db->selectOne(
"SELECT id, status, membership_number, membership_value FROM members WHERE id = ? AND is_archived = 0",
[$sourceMemberId]
);
if (!$sourceMember) {
throw new \RuntimeException('العضو المصدر غير موجود');
}
if ($sourceMember['status'] !== 'active') {
throw new \RuntimeException('العضو المصدر غير نشط - لا يمكن التنازل');
}
// Count dependants (spouses + children) on source
$spouseCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM spouses WHERE member_id = ? AND is_archived = 0",
[$sourceMemberId]
)['cnt'] ?? 0);
$childCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0",
[$sourceMemberId]
)['cnt'] ?? 0);
$totalDependants = $spouseCount + $childCount;
// Calculate fee
$feeData = self::calculateWaiverFee($sourceMemberId);
// Create transfer_request record
$now = date('Y-m-d H:i:s');
$db->insert('transfer_requests', [
'source_member_id' => $sourceMemberId,
'transfer_type' => 'waiver',
'source_membership_number' => $sourceMember['membership_number'],
'new_membership_value' => $sourceMember['membership_value'] ?? '0.00',
'fee_percentage' => self::WAIVER_FEE_PERCENTAGE,
'total_fee' => $feeData['waiver_fee'],
'status' => 'requested',
'notes' => json_encode([
'new_member_data' => $newMemberData,
'source_dependants' => $totalDependants,
'spouse_count' => $spouseCount,
'child_count' => $childCount,
], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
]);
$transferRequestId = (int) $db->selectOne("SELECT LAST_INSERT_ID() as id")['id'];
Logger::info('Waiver request created', [
'transfer_request_id' => $transferRequestId,
'source_member_id' => $sourceMemberId,
'fee' => $feeData['waiver_fee'],
'dependants' => $totalDependants,
]);
return [
'transfer_request_id' => $transferRequestId,
'fee' => $feeData['waiver_fee'],
'conditions' => self::getWaiverConditions(),
];
}
}
......@@ -16,14 +16,27 @@ final class SubscriptionGenerator
$ts = date('Y-m-d H:i:s');
$empId = $employee ? (int) $employee->id : null;
// Get rates from service catalog
$memberRate = self::getRate('SVC_ANNUAL_MEMBER');
$spouseRate = self::getRate('SVC_ANNUAL_SPOUSE');
$childRate = self::getRate('SVC_ANNUAL_CHILD');
$tempRate = self::getRate('SVC_ANNUAL_TEMP');
// Get year-specific rates (e.g. 2024/2025 -> suffix 2024)
$yearParts = explode('/', $financialYear);
$yearSuffix = $yearParts[0] ?? '';
$memberRate = self::getRate('SVC_ANNUAL_MEMBER_' . $yearSuffix) ?: self::getRate('SVC_ANNUAL_MEMBER');
$spouseRate = self::getRate('SVC_ANNUAL_SPOUSE_' . $yearSuffix) ?: self::getRate('SVC_ANNUAL_SPOUSE');
$childRate = self::getRate('SVC_ANNUAL_CHILD_' . $yearSuffix) ?: self::getRate('SVC_ANNUAL_CHILD');
$tempRate = self::getRate('SVC_ANNUAL_TEMP_' . $yearSuffix) ?: self::getRate('SVC_ANNUAL_TEMP');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
// Apply year-specific discount/increase from rules
$yearAdjustment = RuleEngine::get('SUBSCRIPTION_YEAR_ADJUSTMENT_' . $yearSuffix);
if ($yearAdjustment && isset($yearAdjustment['discount_percentage'])) {
$discPct = $yearAdjustment['discount_percentage'];
$multiplier = bcsub('1.00', bcdiv($discPct, '100', 4), 4);
$memberRate = bcmul($memberRate, $multiplier, 2);
$spouseRate = bcmul($spouseRate, $multiplier, 2);
$childRate = bcmul($childRate, $multiplier, 2);
$tempRate = bcmul($tempRate, $multiplier, 2);
}
// Get active members
$memberWhere = "m.status = 'active' AND m.is_archived = 0 AND m.membership_type NOT IN ('honorary')";
$memberParams = [];
......
......@@ -78,6 +78,31 @@ final class SeparationFeeCalculator
return max(1, $diff->y + ($diff->m > 0 || $diff->d > 0 ? 1 : 0)); // partial year rounds up
}
public static function calculateAcquiredMemberChildFee(int $childAge, string $membershipValue): array
{
if ($childAge < 12) {
$ruleCode = 'DIVORCE_CHILD_UNDER_12';
} elseif ($childAge < 16) {
$ruleCode = 'DIVORCE_CHILD_12_TO_16';
} elseif ($childAge < 18) {
$ruleCode = 'DIVORCE_CHILD_16_TO_18';
} else {
$ruleCode = 'DIVORCE_CHILD_OVER_18';
}
$data = RuleEngine::get($ruleCode);
$percentage = $data['percentage'] ?? '30.00';
$fee = bcdiv(bcmul($membershipValue, $percentage, 4), '100', 2);
return [
'age' => $childAge,
'rule_code' => $ruleCode,
'percentage' => $percentage,
'fee' => $fee,
'membership_value' => $membershipValue,
];
}
public static function getFeePercentageByYear(int $year): string
{
$ruleMap = [
......
<?php
declare(strict_types=1);
namespace App\Modules\Transfers\Services;
use App\Core\App;
use App\Modules\Rules\Services\RuleEngine;
final class TransferEligibility
{
public static function canChildSeparate(int $childId): array
{
$db = App::getInstance()->db();
$child = $db->selectOne("SELECT * FROM children WHERE id = ? AND is_archived = 0", [$childId]);
if (!$child) {
return ['eligible' => false, 'reason' => 'لم يتم العثور على بيانات الابن/الابنة'];
}
$age = self::calculateAge($child['date_of_birth']);
$gender = $child['gender'];
if ($gender === 'female') {
return ['eligible' => true, 'reason' => 'البنات: يتم الفصل عند الزواج', 'condition' => 'marriage'];
}
$maxAge = 25;
$ruleData = RuleEngine::get('MALE_CHILD_FREEZE_AGE');
if ($ruleData) {
$maxAge = (int) ($ruleData['value'] ?? 25);
}
if ($age >= $maxAge) {
return ['eligible' => true, 'reason' => 'بلوغ سن ' . $maxAge . ' عام', 'condition' => 'age'];
}
return [
'eligible' => true,
'reason' => 'الأبناء: التخرج من الجامعة ويعمل بأجر أو بلوغ 25 عام إيهما اسبق',
'condition' => 'graduation_or_age',
'requires_verification' => true,
];
}
public static function canSpouseSeparateDivorce(int $spouseId, ?string $divorceDate = null): array
{
$db = App::getInstance()->db();
$spouse = $db->selectOne("SELECT * FROM spouses WHERE id = ? AND is_archived = 0", [$spouseId]);
if (!$spouse) {
return ['eligible' => false, 'reason' => 'لم يتم العثور على بيانات الزوج/الزوجة'];
}
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $spouse['member_id']]);
if (!$member) {
return ['eligible' => false, 'reason' => 'العضو الأساسي غير موجود'];
}
$divorceWindowData = RuleEngine::get('DIVORCE_REQUEST_WINDOW');
$maxYears = (int) ($divorceWindowData['max_years'] ?? 1);
if ($divorceDate) {
$divorceDateTime = new \DateTime($divorceDate);
$now = new \DateTime();
$diff = $now->diff($divorceDateTime);
if ($diff->y >= $maxYears) {
return ['eligible' => false, 'reason' => 'مر أكثر من ' . $maxYears . ' عام على تاريخ الطلاق'];
}
}
$minYearsData = RuleEngine::get('DIVORCE_MIN_MEMBERSHIP_YEARS');
$minYears = (int) ($minYearsData['min_years'] ?? 5);
$waivedIfChildren = (bool) ($minYearsData['waived_if_children'] ?? true);
$membershipDate = $spouse['join_date'] ?? $spouse['marriage_date'] ?? $member['created_at'];
$membershipYears = self::calculateAge($membershipDate);
if ($membershipYears < $minYears) {
if ($waivedIfChildren) {
$childCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0",
[(int) $spouse['member_id']]
)['cnt'] ?? 0);
if ($childCount === 0) {
return [
'eligible' => false,
'reason' => 'لم تمر ' . $minYears . ' سنوات على اكتساب العضوية ولا يوجد أبناء',
];
}
} else {
return [
'eligible' => false,
'reason' => 'لم تمر ' . $minYears . ' سنوات على اكتساب العضوية',
];
}
}
return ['eligible' => true, 'reason' => 'مستوفي شروط التحويل'];
}
public static function getDivorceTransferFee(int $spouseId, string $membershipValue): array
{
$db = App::getInstance()->db();
$spouse = $db->selectOne("SELECT * FROM spouses WHERE id = ?", [$spouseId]);
if (!$spouse) {
return ['error' => 'الزوج/الزوجة غير موجود'];
}
$classification = $spouse['classification'] ?? 'working';
if ($classification === 'working') {
return [
'scenario' => 'both_working',
'fee_type' => 'annual_subscription_only',
'percentage' => '0.00',
'separation_fee' => '0.00',
'description' => 'زوج/زوجة أعضاء عاملين — الاشتراك السنوي فقط',
];
}
if ($classification === 'initial_form' || $spouse['spouse_order'] == 1) {
$data = RuleEngine::get('DIVORCE_SAME_FORM_FEE');
$pct = $data['percentage'] ?? '10.00';
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'scenario' => 'same_form',
'fee_type' => 'percentage',
'percentage' => $pct,
'separation_fee' => $fee,
'treat_as' => 'membership_basis',
'description' => 'تم قبولهم في استمارة العضوية — 10% من قيمة العضوية',
];
}
$data = RuleEngine::get('DIVORCE_JOINED_AFTER_FEE');
$pct = $data['percentage'] ?? '50.00';
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return [
'scenario' => 'joined_after',
'fee_type' => 'percentage',
'percentage' => $pct,
'separation_fee' => $fee,
'treat_as' => 'acquired_member',
'description' => 'تم الانضمام بعد الحصول على العضوية — 50% من قيمة العضوية',
];
}
public static function canWaiver(int $memberId): array
{
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if (!$member) {
return ['eligible' => false, 'reason' => 'العضو غير موجود'];
}
if ($member['status'] !== 'active') {
return ['eligible' => false, 'reason' => 'العضوية غير فعالة'];
}
$unpaidSubs = $db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')",
[$memberId]
);
if ((int) ($unpaidSubs['cnt'] ?? 0) > 0) {
return ['eligible' => false, 'reason' => 'يجب سداد جميع الاشتراكات المتأخرة قبل التنازل'];
}
return [
'eligible' => true,
'reason' => 'مستوفي شروط التنازل — يتطلب موافقة مجلس الأمناء',
'requires_board_approval' => true,
];
}
private static function calculateAge(string $dateOfBirth): int
{
$dob = new \DateTime(substr($dateOfBirth, 0, 10));
$now = new \DateTime();
return $now->diff($dob)->y;
}
}
<?php
declare(strict_types=1);
/**
* Phase 46: Subscription Rates & Membership Prices
*
* Seeds annual subscription rates per financial year (2023–2025),
* current membership prices (with historical entries), and
* the new numbering system business rule.
*/
return function (\App\Core\Database $db) {
$now = date('Y-m-d H:i:s');
// ─── Check service_catalog table exists ────────────────────────────────────
$tableExists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'service_catalog'"
);
if (!$tableExists) {
echo " [SKIP] service_catalog table does not exist.\n";
return;
}
// ─── 1. Annual subscription rates per financial year ────────────────────────
$annualRates = [
// FY 2023 (2023-07-01 to 2024-06-30)
['SVC_ANNUAL_MEMBER_2023', 'اشتراك سنوي - عضو 2023', 'Annual Subscription - Member 2023', 410.00, '2023-07-01', '2024-06-30'],
['SVC_ANNUAL_SPOUSE_2023', 'اشتراك سنوي - زوج/ة 2023', 'Annual Subscription - Spouse 2023', 410.00, '2023-07-01', '2024-06-30'],
['SVC_ANNUAL_CHILD_2023', 'اشتراك سنوي - ابن 2023', 'Annual Subscription - Child 2023', 185.00, '2023-07-01', '2024-06-30'],
['SVC_ANNUAL_TEMP_2023', 'اشتراك سنوي - مؤقت 2023', 'Annual Subscription - Temp 2023', 185.00, '2023-07-01', '2024-06-30'],
// FY 2024 (2024-07-01 to 2025-06-30)
['SVC_ANNUAL_MEMBER_2024', 'اشتراك سنوي - عضو 2024', 'Annual Subscription - Member 2024', 410.00, '2024-07-01', '2025-06-30'],
['SVC_ANNUAL_SPOUSE_2024', 'اشتراك سنوي - زوج/ة 2024', 'Annual Subscription - Spouse 2024', 410.00, '2024-07-01', '2025-06-30'],
['SVC_ANNUAL_CHILD_2024', 'اشتراك سنوي - ابن 2024', 'Annual Subscription - Child 2024', 185.00, '2024-07-01', '2025-06-30'],
['SVC_ANNUAL_TEMP_2024', 'اشتراك سنوي - مؤقت 2024', 'Annual Subscription - Temp 2024', 185.00, '2024-07-01', '2025-06-30'],
// FY 2025 (2025-07-01 to 2026-06-30) — 20% increase
['SVC_ANNUAL_MEMBER_2025', 'اشتراك سنوي - عضو 2025', 'Annual Subscription - Member 2025', 492.00, '2025-07-01', '2026-06-30'],
['SVC_ANNUAL_SPOUSE_2025', 'اشتراك سنوي - زوج/ة 2025', 'Annual Subscription - Spouse 2025', 492.00, '2025-07-01', '2026-06-30'],
['SVC_ANNUAL_CHILD_2025', 'اشتراك سنوي - ابن 2025', 'Annual Subscription - Child 2025', 222.00, '2025-07-01', '2026-06-30'],
['SVC_ANNUAL_TEMP_2025', 'اشتراك سنوي - مؤقت 2025', 'Annual Subscription - Temp 2025', 222.00, '2025-07-01', '2026-06-30'],
// General fallback rates (no effective_to)
['SVC_ANNUAL_MEMBER', 'اشتراك سنوي - عضو', 'Annual Subscription - Member', 492.00, '2025-07-01', null],
['SVC_ANNUAL_SPOUSE', 'اشتراك سنوي - زوج/ة', 'Annual Subscription - Spouse', 492.00, '2025-07-01', null],
['SVC_ANNUAL_CHILD', 'اشتراك سنوي - ابن', 'Annual Subscription - Child', 222.00, '2025-07-01', null],
['SVC_ANNUAL_TEMP', 'اشتراك سنوي - مؤقت', 'Annual Subscription - Temp', 222.00, '2025-07-01', null],
];
foreach ($annualRates as [$code, $nameAr, $nameEn, $amount, $from, $to]) {
$db->query(
"INSERT INTO service_catalog (service_code, name_ar, name_en, category, base_amount, branch_id, effective_from, effective_to, is_active, created_at, updated_at)
VALUES (?, ?, ?, 'subscription', ?, NULL, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
base_amount = VALUES(base_amount),
effective_to = VALUES(effective_to),
is_active = 1,
updated_at = VALUES(updated_at)",
[$code, $nameAr, $nameEn, $amount, $from, $to, $now, $now]
);
}
echo " [OK] Annual subscription rates seeded (2023–2025 + fallback).\n";
// ─── 2. Membership prices ───────────────────────────────────────────────────
$membershipPrices = [
// Current prices (effective 2024-07-01, no end date)
['SVC_MEMBERSHIP_HIGH_QUAL', 'رسوم عضوية - مؤهل عالي', 'Membership Fee - High Qualification', 150000.00, '2024-07-01', null],
['SVC_MEMBERSHIP_MED_QUAL', 'رسوم عضوية - مؤهل متوسط', 'Membership Fee - Medium Qualification', 225000.00, '2024-07-01', null],
['SVC_MEMBERSHIP_NO_QUAL', 'رسوم عضوية - بدون مؤهل', 'Membership Fee - No Qualification', 300000.00, '2024-07-01', null],
// Historical prices (before 2024-07-01)
['SVC_MEMBERSHIP_HIGH_QUAL_OLD', 'رسوم عضوية - مؤهل عالي (قديم)', 'Membership Fee - High Qualification (Old)', 114000.00, '2023-01-01', '2024-06-30'],
['SVC_MEMBERSHIP_MED_QUAL_OLD', 'رسوم عضوية - مؤهل متوسط (قديم)', 'Membership Fee - Medium Qualification (Old)', 171000.00, '2023-01-01', '2024-06-30'],
['SVC_MEMBERSHIP_NO_QUAL_OLD', 'رسوم عضوية - بدون مؤهل (قديم)', 'Membership Fee - No Qualification (Old)', 228000.00, '2023-01-01', '2024-06-30'],
];
foreach ($membershipPrices as [$code, $nameAr, $nameEn, $amount, $from, $to]) {
$db->query(
"INSERT INTO service_catalog (service_code, name_ar, name_en, category, base_amount, branch_id, effective_from, effective_to, is_active, created_at, updated_at)
VALUES (?, ?, ?, 'membership', ?, NULL, ?, ?, 1, ?, ?)
ON DUPLICATE KEY UPDATE
name_ar = VALUES(name_ar),
name_en = VALUES(name_en),
base_amount = VALUES(base_amount),
effective_to = VALUES(effective_to),
is_active = 1,
updated_at = VALUES(updated_at)",
[$code, $nameAr, $nameEn, $amount, $from, $to, $now, $now]
);
}
echo " [OK] Membership prices seeded (current + historical).\n";
// ─── 3. Business rule: new numbering system ─────────────────────────────────
$ruleTableExists = $db->selectOne(
"SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'business_rules'"
);
if (!$ruleTableExists) {
echo " [SKIP] business_rules table does not exist.\n";
return;
}
$ruleCode = 'NEW_NUMBERING_START_DATE';
$ruleValue = json_encode(['date' => '2026-07-01', 'format' => '2/{seq}', 'start_seq' => 1001], JSON_UNESCAPED_UNICODE);
$existingRule = $db->selectOne(
"SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL",
[$ruleCode]
);
if ($existingRule) {
$db->update('business_rules', [
'current_value_json' => $ruleValue,
'updated_at' => $now,
], 'id = ?', [(int) $existingRule['id']]);
} else {
$db->insert('business_rules', [
'rule_code' => $ruleCode,
'category' => 'workflow',
'name_ar' => 'تاريخ بدء نظام الترقيم الجديد',
'name_en' => 'New Numbering System Start Date',
'description_ar' => 'يحدد تاريخ بدء نظام الترقيم الجديد وصيغة الرقم ورقم البداية',
'description_en' => 'Defines the start date, format, and starting sequence for the new numbering system',
'parameters_json' => null,
'current_value_json' => $ruleValue,
'data_type' => 'json',
'branch_id' => null,
'effective_from' => '2026-07-01',
'effective_to' => null,
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => null,
'updated_by' => null,
]);
}
echo " [OK] Business rule NEW_NUMBERING_START_DATE seeded.\n";
// ─── 4. Separation fee tiers (رسوم الفصل) ─────────────────────────────────
$separationRules = [
['SEPARATION_FEE_YEAR_1', 'رسم فصل - السنة الأولى', '{"percentage":"30"}'],
['SEPARATION_FEE_YEAR_2', 'رسم فصل - السنة الثانية', '{"percentage":"20"}'],
['SEPARATION_FEE_YEAR_3', 'رسم فصل - السنة الثالثة', '{"percentage":"15"}'],
['SEPARATION_FEE_YEAR_4', 'رسم فصل - السنة الرابعة', '{"percentage":"10"}'],
['SEPARATION_FEE_YEAR_5', 'رسم فصل - السنة الخامسة', '{"percentage":"5"}'],
['SEPARATION_FEE_YEAR_6_PLUS', 'رسم فصل - السنة السادسة فما فوق', '{"percentage":"2.5"}'],
];
foreach ($separationRules as [$code, $nameAr, $value]) {
$existing = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL", [$code]);
if (!$existing) {
$db->insert('business_rules', [
'rule_code' => $code,
'category' => 'transfer',
'name_ar' => $nameAr,
'name_en' => 'Separation Fee - ' . $code,
'description_ar' => 'نسبة رسم الفصل من قيمة العضوية الجديدة',
'description_en' => 'Separation fee percentage from current membership value',
'parameters_json' => null,
'current_value_json' => $value,
'data_type' => 'json',
'branch_id' => null,
'effective_from' => '2024-07-01',
'effective_to' => null,
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => null,
'updated_by' => null,
]);
}
}
echo " [OK] Separation fee tiers seeded (year 1-6+).\n";
// ─── 5. Form fees & key business rules ────────────────────────────────────
$miscRules = [
['FORM_NEW_MEMBERSHIP_FEE', 'رسم استمارة عضوية جديدة', '{"form_amount":"500","martyrs_stamp":"5","total":"505"}', 'membership'],
['FORM_ADDITION_FEE', 'رسم استمارة إضافة', '{"amount":"570"}', 'membership'],
['FORM_TRANSFER_FEE', 'رسم استمارة فصل/تحويل', '{"amount":"570"}', 'transfer'],
['TEMPORARY_MEMBER_FEE', 'رسم العضو المؤقت', '{"percentage":"10"}', 'membership'],
['NANNY_FEE', 'رسم المربية', '{"percentage":"5"}', 'membership'],
['WAIVER_FEE', 'رسم التنازل عن العضوية', '{"percentage":"30"}', 'transfer'],
['ATHLETIC_CONVERSION_FEE', 'رسم تحويل العضوية الرياضية', '{"percentage":"50","min_years":8}', 'transfer'],
['FOREIGN_MEMBER_FEE', 'رسم العضو الأجنبي', '{"fee_usd":"10000","currency":"USD"}', 'membership'],
['MALE_CHILD_FREEZE_AGE', 'سن تجميد عضوية الابن', '{"value":25}', 'workflow'],
['DIVORCE_REQUEST_WINDOW', 'مهلة طلب فصل العضوية بعد الطلاق', '{"max_years":1}', 'transfer'],
['DIVORCE_MIN_MEMBERSHIP_YEARS', 'حد أدنى سنوات عضوية للطلاق', '{"min_years":5,"waived_if_children":true}', 'transfer'],
['DIVORCE_SAME_FORM_FEE', 'رسم فصل زوج/ة بنفس الاستمارة', '{"percentage":"10"}', 'transfer'],
['DIVORCE_JOINED_AFTER_FEE', 'رسم فصل زوج/ة منضم بعد العضوية', '{"percentage":"50"}', 'transfer'],
['FINE_ACCUMULATION_MAX_YEARS', 'حد أقصى تراكم الغرامات', '{"max_years":5,"drop_after":true}', 'penalties'],
['MEMBERSHIP_DROP_UNPAID_YEARS', 'إسقاط العضوية لعدم السداد', '{"years":5}', 'penalties'],
['REINSTATEMENT_WINDOW', 'مهلة إعادة العضوية بعد الإسقاط', '{"max_years":1}', 'workflow'],
['DEVELOPMENT_FEE', 'رسوم تنمية', '{"amount":"35"}', 'subscription'],
];
foreach ($miscRules as [$code, $nameAr, $value, $category]) {
$existing = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL", [$code]);
if (!$existing) {
$db->insert('business_rules', [
'rule_code' => $code,
'category' => $category,
'name_ar' => $nameAr,
'name_en' => $code,
'description_ar' => $nameAr,
'description_en' => $code,
'parameters_json' => null,
'current_value_json' => $value,
'data_type' => 'json',
'branch_id' => null,
'effective_from' => '2024-07-01',
'effective_to' => null,
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => null,
'updated_by' => null,
]);
}
}
echo " [OK] All business rules seeded (forms, fees, transfers, penalties).\n";
// ─── 6. Subscription year adjustment rules ────────────────────────────────
$yearAdjustments = [
['SUBSCRIPTION_YEAR_ADJUSTMENT_2023', 'تعديل اشتراك 2023 - خصم 50%', '{"discount_percentage":"50","note":"خصم 50% مطبق"}'],
['SUBSCRIPTION_YEAR_ADJUSTMENT_2025', 'تعديل اشتراك 2025 - زيادة 20%', '{"increase_percentage":"20","note":"زيادة 20% عن السنة السابقة"}'],
];
foreach ($yearAdjustments as [$code, $nameAr, $value]) {
$existing = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL", [$code]);
if (!$existing) {
$db->insert('business_rules', [
'rule_code' => $code,
'category' => 'subscription',
'name_ar' => $nameAr,
'name_en' => $code,
'description_ar' => $nameAr,
'description_en' => 'Year-specific subscription adjustment',
'parameters_json' => null,
'current_value_json' => $value,
'data_type' => 'json',
'branch_id' => null,
'effective_from' => '2024-07-01',
'effective_to' => null,
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => null,
'updated_by' => null,
]);
}
}
echo " [OK] Subscription year adjustments seeded (2023 discount, 2025 increase).\n";
// ─── 7. Acquired member children fee tiers (أبناء العضو المكتسب) ──────────
$acquiredChildRules = [
['DIVORCE_CHILD_UNDER_12', 'رسم ابن مكتسب عضوية - أقل من 12', '{"percentage":"15"}'],
['DIVORCE_CHILD_12_TO_16', 'رسم ابن مكتسب عضوية - 12 إلى 16', '{"percentage":"20"}'],
['DIVORCE_CHILD_16_TO_18', 'رسم ابن مكتسب عضوية - 16 إلى 18', '{"percentage":"25"}'],
['DIVORCE_CHILD_OVER_18', 'رسم ابن مكتسب عضوية - فوق 18', '{"percentage":"30"}'],
];
foreach ($acquiredChildRules as [$code, $nameAr, $value]) {
$existing = $db->selectOne("SELECT id FROM business_rules WHERE rule_code = ? AND branch_id IS NULL", [$code]);
if (!$existing) {
$db->insert('business_rules', [
'rule_code' => $code,
'category' => 'transfer',
'name_ar' => $nameAr,
'name_en' => $code,
'description_ar' => 'نسبة رسوم إضافة أبناء العضو المكتسب بعد الانفصال',
'description_en' => 'Fee percentage for acquired member children after separation',
'parameters_json' => null,
'current_value_json' => $value,
'data_type' => 'json',
'branch_id' => null,
'effective_from' => '2024-07-01',
'effective_to' => null,
'is_active' => 1,
'version' => 1,
'created_at' => $now,
'updated_at' => $now,
'created_by' => null,
'updated_by' => null,
]);
}
}
echo " [OK] Acquired member children fee tiers seeded.\n";
};
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