Commit 9eb7700d authored by Mahmoud Aglan's avatar Mahmoud Aglan

lets push

parent 35e90bcb
This diff is collapsed.
...@@ -63,6 +63,7 @@ final class ChildFeeCalculator ...@@ -63,6 +63,7 @@ final class ChildFeeCalculator
'fee' => '0.00', 'fee' => '0.00',
'percentage' => '0.00', 'percentage' => '0.00',
'form_fee' => '0.00', 'form_fee' => '0.00',
'annual_subscription' => '0.00',
'total_fee' => '0.00', 'total_fee' => '0.00',
'rule_applied' => $feeResult['rule_applied'] ?? '', 'rule_applied' => $feeResult['rule_applied'] ?? '',
'error' => $feeResult['error'] ?? 'سن الابن/الابنة يتجاوز الحد المسموح', 'error' => $feeResult['error'] ?? 'سن الابن/الابنة يتجاوز الحد المسموح',
...@@ -73,7 +74,11 @@ final class ChildFeeCalculator ...@@ -73,7 +74,11 @@ final class ChildFeeCalculator
$percentage = $feeResult['percentage'] ?? '0.00'; $percentage = $feeResult['percentage'] ?? '0.00';
$ruleApplied = $feeResult['rule_applied'] ?? ''; $ruleApplied = $feeResult['rule_applied'] ?? '';
$formFee = FormFeeService::getFormFee($memberId, $member); $formFeeOnly = FormFeeService::getFormFeeOnly($memberId, $member);
$annualSubscription = !FormFeeService::isOnInitialForm($member)
? FormFeeService::getAnnualSubscriptionForAddition()
: '0.00';
$formFee = bcadd($formFeeOnly, $annualSubscription, 2);
$totalFee = bcadd($childFee, $formFee, 2); $totalFee = bcadd($childFee, $formFee, 2);
$classLabels = [ $classLabels = [
...@@ -88,17 +93,25 @@ final class ChildFeeCalculator ...@@ -88,17 +93,25 @@ final class ChildFeeCalculator
$breakdown[] = '👶 الترتيب: #' . $childOrder . ' — السن: ' . $childAge . ' سنة'; $breakdown[] = '👶 الترتيب: #' . $childOrder . ' — السن: ' . $childAge . ' سنة';
$breakdown[] = '📌 التصنيف: ' . ($classLabels[$classification] ?? $classification); $breakdown[] = '📌 التصنيف: ' . ($classLabels[$classification] ?? $classification);
if ($classification === 'included' && bccomp($formFee, '0', 2) <= 0) { if ($classification === 'included' && bccomp($formFeeOnly, '0', 2) <= 0 && bccomp($annualSubscription, '0', 2) <= 0) {
$breakdown[] = '✅ مشمول في قيمة العضوية — بدون رسوم إضافية'; $breakdown[] = '✅ مشمول في قيمة العضوية — بدون رسوم إضافية';
} elseif ($classification === 'included' && bccomp($formFee, '0', 2) > 0) { } elseif ($classification === 'included') {
$breakdown[] = '✅ مشمول (بدون رسوم عضوية)'; $breakdown[] = '✅ مشمول (بدون رسوم عضوية)';
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee); if (bccomp($formFeeOnly, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFeeOnly);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$breakdown[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
}
} else { } else {
if (bccomp($childFee, '0', 2) > 0) { if (bccomp($childFee, '0', 2) > 0) {
$breakdown[] = '📊 نسبة ' . $percentage . '% × ' . money($membershipValue) . ' = ' . money($childFee); $breakdown[] = '📊 نسبة ' . $percentage . '% × ' . money($membershipValue) . ' = ' . money($childFee);
} }
if (bccomp($formFee, '0', 2) > 0) { if (bccomp($formFeeOnly, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee); $breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFeeOnly);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$breakdown[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
} }
} }
$breakdown[] = '═══════════════════════════'; $breakdown[] = '═══════════════════════════';
...@@ -112,7 +125,8 @@ final class ChildFeeCalculator ...@@ -112,7 +125,8 @@ final class ChildFeeCalculator
'classification' => $classification, 'classification' => $classification,
'fee' => $childFee, 'fee' => $childFee,
'percentage' => $percentage, 'percentage' => $percentage,
'form_fee' => $formFee, 'form_fee' => $formFeeOnly,
'annual_subscription' => $annualSubscription,
'total_fee' => $totalFee, 'total_fee' => $totalFee,
'rule_applied' => $ruleApplied, 'rule_applied' => $ruleApplied,
'error' => null, 'error' => null,
......
...@@ -323,16 +323,32 @@ class DeathController extends Controller ...@@ -323,16 +323,32 @@ class DeathController extends Controller
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $case['member_id']]); $member = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $case['member_id']]);
$spouse = $db->selectOne("SELECT * FROM spouses WHERE id = ?", [(int) $case['primary_spouse_id']]); $spouse = $db->selectOne("SELECT * FROM spouses WHERE id = ?", [(int) $case['primary_spouse_id']]);
// Extract form data stored during fill-form step
$formData = []; $formData = [];
if (!empty($case['notes'])) { if (!empty($case['notes'])) {
$notesData = json_decode($case['notes'], true); $notesData = json_decode($case['notes'], true);
$formData = $notesData['form_data'] ?? []; $formData = $notesData['form_data'] ?? [];
} }
// Create new member from form data with SAME membership number $inheritedNumber = $member['membership_number'];
// Find the death_fee payment that was made for this case
$deathPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'death_fee' AND related_entity_type = 'death_cases' AND related_entity_id = ? AND is_voided = 0 ORDER BY id DESC LIMIT 1",
[(int) $case['member_id'], (int) $id]
);
$deathPaymentId = $deathPayment ? (int) $deathPayment['id'] : null;
// STEP 1: Archive the deceased member FIRST to release the membership_number
$db->update('members', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'status' => 'deceased',
'membership_number' => null,
], '`id` = ?', [(int) $case['member_id']]);
// STEP 2: Now safe to insert the new member with the inherited number
$newMemberData = [ $newMemberData = [
'membership_number' => $member['membership_number'], 'membership_number' => $inheritedNumber,
'full_name_ar' => $formData['full_name_ar'] ?? $spouse['full_name_ar'], 'full_name_ar' => $formData['full_name_ar'] ?? $spouse['full_name_ar'],
'full_name_en' => $formData['full_name_en'] ?? $spouse['full_name_en'] ?? null, 'full_name_en' => $formData['full_name_en'] ?? $spouse['full_name_en'] ?? null,
'national_id' => $formData['national_id'] ?? $spouse['national_id'], 'national_id' => $formData['national_id'] ?? $spouse['national_id'],
...@@ -343,16 +359,16 @@ class DeathController extends Controller ...@@ -343,16 +359,16 @@ class DeathController extends Controller
'membership_type' => 'working', '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_by_payment_id'=> $deathPaymentId,
'qualification_id' => !empty($formData['qualification_id']) ? (int) $formData['qualification_id'] : ($spouse['qualification_id'] ?? $member['qualification_id']), 'qualification_id' => !empty($formData['qualification_id']) ? (int) $formData['qualification_id'] : ($spouse['qualification_id'] ?? $member['qualification_id']),
'phone_mobile' => $formData['phone_mobile'] ?? $spouse['mobile'] ?? $member['phone_mobile'], 'phone_mobile' => $formData['phone_mobile'] ?? $spouse['mobile'] ?? $member['phone_mobile'],
'membership_value' => $member['membership_value'], 'membership_value' => $member['membership_value'],
'transferred_from_divorce_id' => null,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]; ];
// Add optional form fields
$optionalFields = ['marital_status', 'religion', 'phone_home', 'email', 'emergency_name', 'emergency_phone', 'residence_address', 'area', 'governorate', 'occupation', 'job_title']; $optionalFields = ['marital_status', 'religion', 'phone_home', 'email', 'emergency_name', 'emergency_phone', 'residence_address', 'area', 'governorate', 'occupation', 'job_title'];
foreach ($optionalFields as $field) { foreach ($optionalFields as $field) {
if (!empty($formData[$field])) { if (!empty($formData[$field])) {
...@@ -362,12 +378,6 @@ class DeathController extends Controller ...@@ -362,12 +378,6 @@ class DeathController extends Controller
$newMemberId = $db->insert('members', $newMemberData); $newMemberId = $db->insert('members', $newMemberData);
// Archive old member
$db->update('members', [
'is_archived' => 1, 'archived_at' => date('Y-m-d H:i:s'),
'status' => 'deceased', 'membership_number' => null,
], '`id` = ?', [(int) $case['member_id']]);
// Archive primary spouse record // Archive primary spouse record
$db->update('spouses', [ $db->update('spouses', [
'status' => 'transferred', 'is_archived' => 1, 'status' => 'transferred', 'is_archived' => 1,
...@@ -382,20 +392,19 @@ class DeathController extends Controller ...@@ -382,20 +392,19 @@ class DeathController extends Controller
$db->update('children', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')], $db->update('children', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ? AND `is_archived` = 0', [(int) $childId]); '`id` = ? AND `is_archived` = 0', [(int) $childId]);
} }
// Children assigned to secondary spouses will be moved when secondary memberships are created
} }
} else { } else {
// Default: all children go to primary
$db->update('children', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')], $db->update('children', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`member_id` = ? AND `is_archived` = 0', [(int) $case['member_id']]); '`member_id` = ? AND `is_archived` = 0', [(int) $case['member_id']]);
} }
// Handle secondary spouses — create separate new memberships // Handle secondary spouses — create separate new memberships with NEW numbers
if (!empty($case['secondary_spouses_json'])) { if (!empty($case['secondary_spouses_json'])) {
$secondaryIds = json_decode($case['secondary_spouses_json'], true); $secondaryIds = json_decode($case['secondary_spouses_json'], true);
foreach ($secondaryIds as $secSpouseId) { foreach ($secondaryIds as $secSpouseId) {
$secSpouse = $db->selectOne("SELECT * FROM spouses WHERE id = ?", [(int) $secSpouseId]); $secSpouse = $db->selectOne("SELECT * FROM spouses WHERE id = ?", [(int) $secSpouseId]);
if ($secSpouse) { if (!$secSpouse) continue;
$secMemberId = $db->insert('members', [ $secMemberId = $db->insert('members', [
'full_name_ar' => $secSpouse['full_name_ar'], 'full_name_ar' => $secSpouse['full_name_ar'],
'full_name_en' => $secSpouse['full_name_en'] ?? null, 'full_name_en' => $secSpouse['full_name_en'] ?? null,
...@@ -406,7 +415,9 @@ class DeathController extends Controller ...@@ -406,7 +415,9 @@ class DeathController extends Controller
'branch_id' => (int) $member['branch_id'], 'branch_id' => (int) $member['branch_id'],
'membership_type' => 'working', 'membership_type' => 'working',
'member_category' => 'working_member', 'member_category' => 'working_member',
'status' => 'form_pending', 'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'),
'activated_by_payment_id'=> $deathPaymentId,
'phone_mobile' => $secSpouse['mobile'] ?? null, 'phone_mobile' => $secSpouse['mobile'] ?? null,
'membership_value' => $member['membership_value'], 'membership_value' => $member['membership_value'],
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
...@@ -414,16 +425,14 @@ class DeathController extends Controller ...@@ -414,16 +425,14 @@ class DeathController extends Controller
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]); ]);
// Assign new membership number for secondary
\App\Modules\Members\Services\MemberNumberGenerator::assign($secMemberId); \App\Modules\Members\Services\MemberNumberGenerator::assign($secMemberId);
// Archive secondary spouse
$db->update('spouses', [ $db->update('spouses', [
'status' => 'transferred', 'is_archived' => 1, 'status' => 'transferred', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'), 'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $secSpouseId]); ], '`id` = ?', [(int) $secSpouseId]);
// Move children assigned to this spouse // Move children assigned to this secondary spouse
if (!empty($childrenAssignment)) { if (!empty($childrenAssignment)) {
foreach ($childrenAssignment as $childId => $assignTo) { foreach ($childrenAssignment as $childId => $assignTo) {
if ((int) $assignTo === (int) $secSpouseId) { if ((int) $assignTo === (int) $secSpouseId) {
...@@ -434,14 +443,12 @@ class DeathController extends Controller ...@@ -434,14 +443,12 @@ class DeathController extends Controller
} }
} }
} }
}
// Also transfer temporary_members to new primary // Transfer temporary_members to new primary
$db->update('temporary_members', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')], $db->update('temporary_members', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`member_id` = ? AND `is_archived` = 0', [(int) $case['member_id']]); '`member_id` = ? AND `is_archived` = 0', [(int) $case['member_id']]);
// Record number chain ArchiveService::recordNumberTransfer($inheritedNumber, 'death_transfer', 'members', $newMemberId);
ArchiveService::recordNumberTransfer($member['membership_number'], 'death_transfer', 'members', $newMemberId);
$db->update('death_cases', [ $db->update('death_cases', [
'transferred_to_member_id' => $newMemberId, 'transferred_to_member_id' => $newMemberId,
...@@ -452,7 +459,7 @@ class DeathController extends Controller ...@@ -452,7 +459,7 @@ class DeathController extends Controller
$db->commit(); $db->commit();
EventBus::dispatch('death.completed', ['case_id' => (int) $id, 'new_member_id' => $newMemberId]); EventBus::dispatch('death.completed', ['case_id' => (int) $id, 'new_member_id' => $newMemberId]);
return $this->redirect("/death/{$id}")->withSuccess('تم نقل العضوية بنفس الرقم — رقم العضوية: ' . $member['membership_number']); return $this->redirect("/death/{$id}")->withSuccess('تم نقل العضوية بنفس الرقم — رقم العضوية: ' . $inheritedNumber);
} }
// Non-primary-member death (spouse or child) // Non-primary-member death (spouse or child)
......
...@@ -141,53 +141,46 @@ class MemberController extends Controller ...@@ -141,53 +141,46 @@ class MemberController extends Controller
$qualification = $member->qualification_id ? $db->selectOne("SELECT name_ar FROM qualifications WHERE id = ?", [(int) $member->qualification_id]) : null; $qualification = $member->qualification_id ? $db->selectOne("SELECT name_ar FROM qualifications WHERE id = ?", [(int) $member->qualification_id]) : null;
$spouses = []; $children = []; $temporaries = []; $spouses = []; $children = []; $temporaries = [];
try { $spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY spouse_order", [(int) $id]); } catch (\Throwable $e) {} try {
try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e) {} $spouses = $db->select(
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e) {} "SELECT s.*, pr.created_at AS due_date, p.payment_date
FROM spouses s
// Self-healing: fix dependents stuck at pending_payment after membership was paid LEFT JOIN payment_requests pr ON pr.related_entity_type = 'spouses' AND pr.related_entity_id = s.id AND pr.payment_type = 'addition_fee' AND pr.is_voided = 0
if ($member->status === 'active') { LEFT JOIN payments p ON p.id = s.activated_by_payment_id AND p.is_voided = 0
$membershipPaid = $db->selectOne( WHERE s.member_id = ? AND s.is_archived = 0
"SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment') AND is_voided = 0 LIMIT 1", ORDER BY s.spouse_order", [(int) $id]
[(int) $id]
);
if ($membershipPaid) {
$fixedAny = false;
foreach (['spouses', 'children', 'temporary_members'] as $tbl) {
$stuck = $db->select(
"SELECT id FROM `{$tbl}` WHERE member_id = ? AND status = 'pending_payment' AND is_archived = 0",
[(int) $id]
);
foreach ($stuck as $dep) {
$hasPending = $db->selectOne(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status IN ('pending','processing') AND is_voided = 0 LIMIT 1",
[(int) $id, $tbl, (int) $dep['id']]
);
$hasCompletedPayment = $db->selectOne(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status = 'completed' AND is_voided = 0 LIMIT 1",
[(int) $id, $tbl, (int) $dep['id']]
); );
$hasCancelled = !$hasCompletedPayment ? $db->selectOne( } catch (\Throwable $e) {
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status = 'cancelled' ORDER BY id DESC LIMIT 1", try { $spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY spouse_order", [(int) $id]); } catch (\Throwable $e2) {}
[(int) $id, $tbl, (int) $dep['id']]
) : null;
if (!$hasPending && !$hasCancelled) {
$upd = ['status' => 'active', 'updated_at' => date('Y-m-d H:i:s')];
if ($tbl === 'spouses') $upd['join_date'] = date('Y-m-d');
$db->update($tbl, $upd, '`id` = ?', [(int) $dep['id']]);
$fixedAny = true;
}
}
}
if ($fixedAny) {
try { $spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY spouse_order", [(int) $id]); } catch (\Throwable $e) {}
try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e) {}
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e) {}
Logger::info("Fixed stuck pending_payment dependants", ['member_id' => (int) $id]);
} }
try {
$children = $db->select(
"SELECT c.*, pr.created_at AS due_date, p.payment_date
FROM children c
LEFT JOIN payment_requests pr ON pr.related_entity_type = 'children' AND pr.related_entity_id = c.id AND pr.payment_type = 'addition_fee' AND pr.is_voided = 0
LEFT JOIN payments p ON p.id = c.activated_by_payment_id AND p.is_voided = 0
WHERE c.member_id = ? AND c.is_archived = 0
ORDER BY c.child_order", [(int) $id]
);
} catch (\Throwable $e) {
try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e2) {}
} }
try {
$temporaries = $db->select(
"SELECT t.*, pr.created_at AS due_date, p.payment_date
FROM temporary_members t
LEFT JOIN payment_requests pr ON pr.related_entity_type = 'temporary_members' AND pr.related_entity_id = t.id AND pr.payment_type = 'addition_fee' AND pr.is_voided = 0
LEFT JOIN payments p ON p.id = t.activated_by_payment_id AND p.is_voided = 0
WHERE t.member_id = ? AND t.is_archived = 0
ORDER BY t.id", [(int) $id]
);
} catch (\Throwable $e) {
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e2) {}
} }
// Reconcile membership status against payment source-of-truth
\App\Modules\Members\Services\MembershipPaymentGuard::reconcile((int) $id);
$bill = BillingService::getMemberBill((int) $id); $bill = BillingService::getMemberBill((int) $id);
$formFilled = ($member->qualification_id !== null && $member->qualification_id > 0); $formFilled = ($member->qualification_id !== null && $member->qualification_id > 0);
...@@ -624,4 +617,66 @@ class MemberController extends Controller ...@@ -624,4 +617,66 @@ class MemberController extends Controller
$q = trim((string) $request->get('q', '')); $q = trim((string) $request->get('q', ''));
return $this->view('Members.Views.search', ['query' => $q, 'results' => ($q !== '' && mb_strlen($q) >= 2) ? MemberSearchService::search($q, 50) : []]); return $this->view('Members.Views.search', ['query' => $q, 'results' => ($q !== '' && mb_strlen($q) >= 2) ? MemberSearchService::search($q, 50) : []]);
} }
public function changelog(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$member = Member::find((int) $id);
if (!$member) {
return $this->redirect('/members')->withError('العضو غير موجود');
}
$spouseIds = array_column(
$db->select("SELECT id FROM spouses WHERE member_id = ? ORDER BY id", [(int) $id]),
'id'
);
$childIds = array_column(
$db->select("SELECT id FROM children WHERE member_id = ? ORDER BY id", [(int) $id]),
'id'
);
$tempIds = array_column(
$db->select("SELECT id FROM temporary_members WHERE member_id = ? ORDER BY id", [(int) $id]),
'id'
);
$memberLogs = $db->select(
"SELECT * FROM audit_trail WHERE entity_type = 'members' AND entity_id = ? ORDER BY created_at DESC LIMIT 200",
[(int) $id]
);
$spouseLogs = [];
if (!empty($spouseIds)) {
$placeholders = implode(',', array_fill(0, count($spouseIds), '?'));
$spouseLogs = $db->select(
"SELECT * FROM audit_trail WHERE entity_type = 'spouses' AND entity_id IN ({$placeholders}) ORDER BY created_at DESC LIMIT 200",
$spouseIds
);
}
$childLogs = [];
if (!empty($childIds)) {
$placeholders = implode(',', array_fill(0, count($childIds), '?'));
$childLogs = $db->select(
"SELECT * FROM audit_trail WHERE entity_type = 'children' AND entity_id IN ({$placeholders}) ORDER BY created_at DESC LIMIT 200",
$childIds
);
}
$tempLogs = [];
if (!empty($tempIds)) {
$placeholders = implode(',', array_fill(0, count($tempIds), '?'));
$tempLogs = $db->select(
"SELECT * FROM audit_trail WHERE entity_type = 'temporary_members' AND entity_id IN ({$placeholders}) ORDER BY created_at DESC LIMIT 200",
$tempIds
);
}
return $this->view('Members.Views.changelog', [
'member' => $member,
'memberLogs' => $memberLogs,
'spouseLogs' => $spouseLogs,
'childLogs' => $childLogs,
'tempLogs' => $tempLogs,
]);
}
} }
\ No newline at end of file
...@@ -15,6 +15,7 @@ return [ ...@@ -15,6 +15,7 @@ return [
['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth', 'csrf'], 'member.pay_membership'], ['POST', '/members/{id}/pay-addition', 'Members\Controllers\MemberController@payAdditionFee', ['auth', 'csrf'], 'member.pay_membership'],
['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'], ['GET', '/members/{id}/fill-form', 'Members\Controllers\MemberController@fillForm', ['auth'], 'member.fill_form'],
['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'], ['POST', '/members/{id}/fill-form', 'Members\Controllers\MemberController@saveFillForm', ['auth', 'csrf'], 'member.fill_form'],
['GET', '/members/{id}/changelog', 'Members\Controllers\MemberController@changelog', ['auth'], 'member.view'],
['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'], ['POST', '/api/members/parse-nid', 'Members\Controllers\MemberApiController@parseNid', ['auth'], 'member.create'],
['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'], ['POST', '/api/members/search', 'Members\Controllers\MemberApiController@search', ['auth'], 'member.view'],
// Reports // Reports
......
...@@ -66,7 +66,7 @@ final class FormFeeService ...@@ -66,7 +66,7 @@ final class FormFeeService
return ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00'); return ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
} }
private static function getAnnualSubscriptionForAddition(): string public static function getAnnualSubscriptionForAddition(): string
{ {
$month = (int) date('n'); $month = (int) date('n');
$year = (int) date('Y'); $year = (int) date('Y');
......
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل التعديلات — <?= e($member->full_name_ar) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
use App\Modules\Audit\Services\AuditService;
$sections = [
'member' => ['label' => 'العضو الرئيسي', 'icon' => '👤', 'logs' => $memberLogs, 'color' => '#0D7377'],
'spouse' => ['label' => 'الزوجات', 'icon' => '💍', 'logs' => $spouseLogs, 'color' => '#7C3AED'],
'child' => ['label' => 'الأبناء', 'icon' => '👶', 'logs' => $childLogs, 'color' => '#0284C7'],
'temp' => ['label' => 'الأعضاء المؤقتون', 'icon' => '👥', 'logs' => $tempLogs, 'color' => '#D97706'],
];
$totalLogs = count($memberLogs) + count($spouseLogs) + count($childLogs) + count($tempLogs);
?>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
<a href="/members/<?= (int) $member->id ?>" class="btn btn-outline btn-sm">&larr; العودة للملف</a>
<h2 style="margin:0;font-size:18px;color:#1F2937;">سجل التعديلات — <?= e($member->full_name_ar) ?></h2>
<span style="background:#E5E7EB;color:#374151;padding:3px 10px;border-radius:12px;font-size:12px;font-weight:600;"><?= $totalLogs ?> تعديل</span>
</div>
<!-- Tab Navigation -->
<div style="display:flex;gap:4px;margin-bottom:20px;border-bottom:2px solid #E5E7EB;padding-bottom:0;">
<?php foreach ($sections as $key => $sec): ?>
<button class="changelog-tab" data-tab="<?= $key ?>" style="padding:10px 20px;border:none;background:none;cursor:pointer;font-size:14px;font-weight:600;color:#6B7280;border-bottom:3px solid transparent;margin-bottom:-2px;transition:all 0.2s;" onclick="switchTab('<?= $key ?>')">
<?= $sec['icon'] ?> <?= $sec['label'] ?> <span style="background:#F3F4F6;color:#374151;padding:1px 6px;border-radius:8px;font-size:11px;margin-right:4px;"><?= count($sec['logs']) ?></span>
</button>
<?php endforeach; ?>
</div>
<!-- Tab Content -->
<?php foreach ($sections as $key => $sec): ?>
<div id="tab-<?= $key ?>" class="changelog-panel" style="display:none;">
<?php if (empty($sec['logs'])): ?>
<div class="card" style="padding:40px;text-align:center;color:#9CA3AF;">
<div style="font-size:48px;margin-bottom:10px;">📋</div>
<p>لا توجد تعديلات مسجلة في هذا القسم</p>
</div>
<?php else: ?>
<div style="display:flex;flex-direction:column;gap:12px;">
<?php foreach ($sec['logs'] as $log):
$action = $log['action'] ?? '';
$actionLabel = AuditService::getActionLabel($action);
$actionColor = AuditService::getActionColor($action);
$actionIcon = AuditService::getActionIcon($action);
$changedFields = !empty($log['changed_fields_json']) ? json_decode($log['changed_fields_json'], true) : null;
$beforeData = !empty($log['before_data_json']) ? json_decode($log['before_data_json'], true) : null;
$afterData = !empty($log['after_data_json']) ? json_decode($log['after_data_json'], true) : null;
$entityLabel = $log['entity_label'] ?? '';
?>
<div class="card" style="padding:0;overflow:hidden;border-right:4px solid <?= $actionColor ?>;">
<!-- Header -->
<div style="padding:12px 16px;display:flex;align-items:center;justify-content:space-between;background:#F9FAFB;border-bottom:1px solid #E5E7EB;">
<div style="display:flex;align-items:center;gap:10px;">
<span style="font-size:18px;"><?= $actionIcon ?></span>
<span style="font-weight:700;color:<?= $actionColor ?>;font-size:14px;"><?= e($actionLabel) ?></span>
<?php if ($entityLabel): ?>
<span style="color:#6B7280;font-size:12px;"><?= e($entityLabel) ?></span>
<?php endif; ?>
</div>
<div style="display:flex;align-items:center;gap:15px;font-size:12px;color:#6B7280;">
<?php if (!empty($log['ip_address'])): ?>
<span title="IP Address" style="display:flex;align-items:center;gap:3px;"><i data-lucide="globe" style="width:13px;height:13px;"></i> <?= e($log['ip_address']) ?></span>
<?php endif; ?>
<span title="التاريخ والوقت" style="display:flex;align-items:center;gap:3px;"><i data-lucide="clock" style="width:13px;height:13px;"></i> <?= e($log['created_at']) ?></span>
<?php if (!empty($log['employee_name'])): ?>
<span title="بواسطة" style="display:flex;align-items:center;gap:3px;"><i data-lucide="user" style="width:13px;height:13px;"></i> <?= e($log['employee_name']) ?></span>
<?php endif; ?>
</div>
</div>
<!-- Body: Changed Fields -->
<div style="padding:12px 16px;">
<?php if (!empty($log['notes'])): ?>
<div style="margin-bottom:10px;padding:8px 12px;background:#FEF3C7;border:1px solid #FCD34D;border-radius:6px;font-size:13px;color:#92400E;">
<strong>السبب:</strong> <?= e($log['notes']) ?>
</div>
<?php endif; ?>
<?php if ($action === 'create' && $afterData): ?>
<div style="font-size:12px;color:#059669;margin-bottom:6px;font-weight:600;">تم إنشاء السجل بالبيانات التالية:</div>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<?php
$displayFields = array_filter($afterData, function($v, $k) {
return !in_array($k, ['id', 'created_at', 'updated_at', 'fee_breakdown_json', 'password_hash'], true) && $v !== null && $v !== '';
}, ARRAY_FILTER_USE_BOTH);
foreach ($displayFields as $field => $value): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:4px 8px;color:#6B7280;width:30%;font-weight:500;"><?= e(AuditService::getFieldLabel($field)) ?></td>
<td style="padding:4px 8px;color:#059669;font-weight:600;"><?= e(AuditService::formatFieldValue($field, $value)) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php elseif ($action === 'delete' && $beforeData): ?>
<div style="font-size:12px;color:#DC2626;margin-bottom:6px;font-weight:600;">تم حذف السجل — البيانات قبل الحذف:</div>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<?php
$displayFields = array_filter($beforeData, function($v, $k) {
return !in_array($k, ['id', 'created_at', 'updated_at', 'fee_breakdown_json', 'password_hash'], true) && $v !== null && $v !== '';
}, ARRAY_FILTER_USE_BOTH);
foreach ($displayFields as $field => $value): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:4px 8px;color:#6B7280;width:30%;font-weight:500;"><?= e(AuditService::getFieldLabel($field)) ?></td>
<td style="padding:4px 8px;color:#DC2626;text-decoration:line-through;"><?= e(AuditService::formatFieldValue($field, $value)) ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php elseif ($changedFields && is_array($changedFields)): ?>
<table style="width:100%;font-size:13px;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:6px 8px;text-align:right;color:#6B7280;font-weight:600;width:30%;">الحقل</th>
<th style="padding:6px 8px;text-align:right;color:#DC2626;font-weight:600;width:35%;">قبل</th>
<th style="padding:6px 8px;text-align:right;color:#059669;font-weight:600;width:35%;">بعد</th>
</tr>
</thead>
<tbody>
<?php foreach ($changedFields as $field => $change): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px 8px;color:#374151;font-weight:600;"><?= e(AuditService::getFieldLabel($field)) ?></td>
<td style="padding:5px 8px;color:#DC2626;background:#FEF2F2;border-radius:3px;">
<?= e(AuditService::formatFieldValue($field, $change['from'] ?? null)) ?>
</td>
<td style="padding:5px 8px;color:#059669;background:#ECFDF5;border-radius:3px;">
<?= e(AuditService::formatFieldValue($field, $change['to'] ?? null)) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<span style="font-size:13px;color:#9CA3AF;">لا توجد تفاصيل إضافية</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<script>
function switchTab(tab) {
document.querySelectorAll('.changelog-panel').forEach(function(p) { p.style.display = 'none'; });
document.querySelectorAll('.changelog-tab').forEach(function(t) {
t.style.color = '#6B7280';
t.style.borderBottomColor = 'transparent';
});
var panel = document.getElementById('tab-' + tab);
var btn = document.querySelector('.changelog-tab[data-tab="' + tab + '"]');
if (panel) panel.style.display = 'block';
if (btn) {
btn.style.color = '#0D7377';
btn.style.borderBottomColor = '#0D7377';
}
}
document.addEventListener('DOMContentLoaded', function() {
switchTab('member');
if (typeof lucide !== 'undefined') lucide.createIcons();
});
</script>
<?php $__template->endSection(); ?>
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
<?php if (empty($member->membership_number) || ($isSuperAdmin ?? false)): ?> <?php if (empty($member->membership_number) || ($isSuperAdmin ?? false)): ?>
<a href="/members/<?= (int) $member->id ?>/edit" class="btn btn-outline">✏️ تعديل</a> <a href="/members/<?= (int) $member->id ?>/edit" class="btn btn-outline">✏️ تعديل</a>
<?php endif; ?> <?php endif; ?>
<a href="/audit/entity/members/<?= (int) $member->id ?>" class="btn btn-outline">📜 سجل المراجعة</a> <a href="/members/<?= (int) $member->id ?>/changelog" class="btn btn-outline">📜 سجل التعديلات</a>
<a href="/members" class="btn btn-outline">← العودة</a> <a href="/members" class="btn btn-outline">← العودة</a>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -625,7 +625,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -625,7 +625,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Spouses --> <!-- Spouses -->
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f48d; الزوجات (<?= count($spouses) ?>)</div> <div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f48d; الزوجات (<?= count($spouses) ?>)</div>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>الرقم القومي</th><th>تاريخ الالتحاق</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody> <div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>الرقم القومي</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($spouses as $sIdx => $s): ?> <?php foreach ($spouses as $sIdx => $s): ?>
<?php <?php
$sFee = $s['addition_fee'] ?? '0.00'; $sFee = $s['addition_fee'] ?? '0.00';
...@@ -642,6 +642,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -642,6 +642,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
?><span style="background:#E0F2FE;color:#0369A1;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;"><?= $sTypeLabel ?></span></td> ?><span style="background:#E0F2FE;color:#0369A1;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;"><?= $sTypeLabel ?></span></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($s['national_id'] ?? '—') ?></td> <td style="direction:ltr;text-align:right;font-size:12px;"><?= e($s['national_id'] ?? '—') ?></td>
<td style="font-size:12px;"><?= $s['join_date'] ? e($s['join_date']) : '<span style="color:#D97706;">لم يُحدد بعد</span>' ?></td> <td style="font-size:12px;"><?= $s['join_date'] ? e($s['join_date']) : '<span style="color:#D97706;">لم يُحدد بعد</span>' ?></td>
<td style="font-size:12px;"><?= !empty($s['due_date']) ? e(substr($s['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($s['payment_date']) ? e($s['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php <td style="font-weight:600;"><?php
$fee = $s['addition_fee'] ?? '0.00'; $fee = $s['addition_fee'] ?? '0.00';
if (bccomp($fee, '0', 2) <= 0 && (int) $s['spouse_order'] === 1) echo '<span style="color:#059669;">مشمولة</span>'; if (bccomp($fee, '0', 2) <= 0 && (int) $s['spouse_order'] === 1) echo '<span style="color:#059669;">مشمولة</span>';
...@@ -664,7 +666,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -664,7 +666,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr> </tr>
<?php if ($sBreakdown): ?> <?php if ($sBreakdown): ?>
<tr id="bill-detail-spouse-<?= $sIdx ?>" style="display:none;"> <tr id="bill-detail-spouse-<?= $sIdx ?>" style="display:none;">
<td colspan="8" style="padding:5px 10px 10px;"> <td colspan="10" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;"> <div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($sBreakdown as $line): ?> <?php foreach ($sBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?> <?php if (str_contains($line, '═══')): ?>
...@@ -688,7 +690,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -688,7 +690,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Children --> <!-- Children -->
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f476; الأبناء (<?= count($children) ?>)</div> <div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f476; الأبناء (<?= count($children) ?>)</div>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody> <div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>#</th><th>الاسم</th><th>نوع العضوية</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($children as $cIdx => $c): ?> <?php foreach ($children as $cIdx => $c): ?>
<?php <?php
$cFee = $c['addition_fee'] ?? '0.00'; $cFee = $c['addition_fee'] ?? '0.00';
...@@ -702,6 +704,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -702,6 +704,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<td><?= $c['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td> <td><?= $c['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td>
<td><?= (int) ($c['age_years'] ?? 0) ?></td> <td><?= (int) ($c['age_years'] ?? 0) ?></td>
<td style="font-size:12px;"><?= !empty($c['join_date']) ? e($c['join_date']) : '<span style="color:#D97706;">—</span>' ?></td> <td style="font-size:12px;"><?= !empty($c['join_date']) ? e($c['join_date']) : '<span style="color:#D97706;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($c['due_date']) ? e(substr($c['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($c['payment_date']) ? e($c['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php <td style="font-weight:600;"><?php
$fee = $c['addition_fee'] ?? '0.00'; $fee = $c['addition_fee'] ?? '0.00';
echo bccomp($fee, '0', 2) <= 0 ? '<span style="color:#059669;">مشمول</span>' : money($fee); echo bccomp($fee, '0', 2) <= 0 ? '<span style="color:#059669;">مشمول</span>' : money($fee);
...@@ -722,7 +726,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -722,7 +726,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr> </tr>
<?php if ($cBreakdown): ?> <?php if ($cBreakdown): ?>
<tr id="bill-detail-child-<?= $cIdx ?>" style="display:none;"> <tr id="bill-detail-child-<?= $cIdx ?>" style="display:none;">
<td colspan="9" style="padding:5px 10px 10px;"> <td colspan="11" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;"> <div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($cBreakdown as $line): ?> <?php foreach ($cBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?> <?php if (str_contains($line, '═══')): ?>
...@@ -746,7 +750,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -746,7 +750,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<!-- Temporary Members --> <!-- Temporary Members -->
<div style="padding:15px 20px;"> <div style="padding:15px 20px;">
<div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f464; الأعضاء المؤقتون (<?= count($temporaries) ?>)</div> <div style="font-size:12px;color:#0D7377;font-weight:700;margin-bottom:10px;text-transform:uppercase;">&#x1f464; الأعضاء المؤقتون (<?= count($temporaries) ?>)</div>
<div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>الاسم</th><th>نوع العضوية</th><th>الصلة</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody> <div class="table-responsive"><table class="data-table" style="margin:0;"><thead><tr><th>الاسم</th><th>نوع العضوية</th><th>الصلة</th><th>النوع</th><th>السن</th><th>تاريخ الالتحاق</th><th>تاريخ الاستحقاق</th><th>تاريخ السداد</th><th>الرسوم</th><th>الحالة</th><th></th></tr></thead><tbody>
<?php foreach ($temporaries as $tIdx => $t): ?> <?php foreach ($temporaries as $tIdx => $t): ?>
<?php <?php
$tFee = $t['addition_fee'] ?? '0.00'; $tFee = $t['addition_fee'] ?? '0.00';
...@@ -760,6 +764,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -760,6 +764,8 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
<td><?= $t['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td> <td><?= $t['gender'] === 'male' ? 'ذكر' : 'أنثى' ?></td>
<td><?= (int) ($t['age_years'] ?? 0) ?></td> <td><?= (int) ($t['age_years'] ?? 0) ?></td>
<td style="font-size:12px;"><?= !empty($t['join_date']) ? e($t['join_date']) : '<span style="color:#D97706;">—</span>' ?></td> <td style="font-size:12px;"><?= !empty($t['join_date']) ? e($t['join_date']) : '<span style="color:#D97706;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($t['due_date']) ? e(substr($t['due_date'], 0, 10)) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-size:12px;"><?= !empty($t['payment_date']) ? e($t['payment_date']) : '<span style="color:#9CA3AF;">—</span>' ?></td>
<td style="font-weight:600;"><?php <td style="font-weight:600;"><?php
$fee = $t['addition_fee'] ?? '0.00'; $fee = $t['addition_fee'] ?? '0.00';
echo bccomp($fee, '0', 2) <= 0 ? '<span style="color:#059669;">مشمول</span>' : money($fee); echo bccomp($fee, '0', 2) <= 0 ? '<span style="color:#059669;">مشمول</span>' : money($fee);
...@@ -781,7 +787,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' = ...@@ -781,7 +787,7 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</tr> </tr>
<?php if ($tBreakdown): ?> <?php if ($tBreakdown): ?>
<tr id="bill-detail-temp-<?= $tIdx ?>" style="display:none;"> <tr id="bill-detail-temp-<?= $tIdx ?>" style="display:none;">
<td colspan="8" style="padding:5px 10px 10px;"> <td colspan="11" style="padding:5px 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;"> <div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($tBreakdown as $line): ?> <?php foreach ($tBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?> <?php if (str_contains($line, '═══')): ?>
......
<?php
declare(strict_types=1);
namespace App\Modules\Payments\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Members\Services\MembershipPaymentGuard;
/**
* Orchestrates payment lifecycle events (completion, void, cancellation)
* with membership status transitions via MembershipPaymentGuard.
*
* This service is the ONLY place that connects payment state changes
* to membership activation/deactivation. The EventBus listeners in
* Cashier/bootstrap.php delegate here — they don't contain logic.
*/
final class PaymentLifecycleService
{
/**
* Handle a completed membership/down_payment — activate member + dependents.
*/
public static function onMembershipPaymentCompleted(int $memberId, int $paymentId, string $paymentType, array $requestData = []): array
{
$result = MembershipPaymentGuard::activateMember($memberId, $paymentId);
if ($result['success'] && empty($result['already_active'])) {
MembershipPaymentGuard::activateIncludedDependents($memberId, $paymentId);
}
if ($result['success'] && $paymentType === 'down_payment') {
self::createInstallmentPlan($memberId, $paymentId, $requestData);
}
return $result;
}
/**
* Handle a completed addition_fee — activate the specific dependent.
*/
public static function onAdditionFeeCompleted(string $entityType, int $entityId, int $memberId, int $paymentId): array
{
$validTables = ['spouses', 'children', 'temporary_members'];
if (!in_array($entityType, $validTables, true)) {
return ['success' => false, 'error' => 'نوع الملحق غير صالح'];
}
return MembershipPaymentGuard::activateDependent($entityType, $entityId, $memberId, $paymentId);
}
/**
* Handle payment void — deactivate member/dependent if no replacement payment exists.
* Called AFTER PaymentService::voidPayment() has already voided the record.
*/
public static function onPaymentVoided(int $paymentId, int $memberId, string $paymentType, ?string $entityType = null, ?int $entityId = null): void
{
if (in_array($paymentType, ['membership_fee', 'down_payment'], true)) {
MembershipPaymentGuard::deactivateMember($memberId, $paymentId);
return;
}
if ($paymentType === 'addition_fee' && $entityType && $entityId > 0) {
$validTables = ['spouses', 'children', 'temporary_members'];
if (in_array($entityType, $validTables, true)) {
MembershipPaymentGuard::deactivateDependent($entityType, $entityId, $paymentId);
}
return;
}
self::revertLifeEventOnVoid($paymentType, $entityType, $entityId);
}
/**
* Handle payment request cancellation (from cashier queue).
* If the request was already completed, its linked payment is voided first
* by PaymentRequestService::cancelRequest(), which triggers payment.voided.
* This handles the case where a pending/processing request is cancelled
* before any payment was made — no membership change needed, but life-event
* status may need reverting.
*/
public static function onPaymentRequestCancelled(array $data): void
{
$paymentType = $data['payment_type'] ?? '';
$entityType = $data['related_entity_type'] ?? null;
$entityId = (int) ($data['related_entity_id'] ?? 0);
self::revertLifeEventOnVoid($paymentType, $entityType, $entityId);
}
/**
* Revert life-event entity status when its fee payment is voided/cancelled.
*/
private static function revertLifeEventOnVoid(string $paymentType, ?string $entityType, ?int $entityId): void
{
if (!$entityId || $entityId <= 0) return;
$db = App::getInstance()->db();
$entityRevertMap = [
'separation_fee' => 'transfer_requests',
'divorce_fee' => 'divorce_cases',
'death_fee' => 'death_cases',
'waiver_fee' => 'waiver_requests',
];
if (isset($entityRevertMap[$paymentType])) {
$table = $entityRevertMap[$paymentType];
$db->update($table, [
'status' => 'requested',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND status = ?', [$entityId, 'fee_paid']);
Logger::info("PaymentLifecycleService: reverted {$table} #{$entityId} to 'requested' after void/cancel");
}
}
/**
* Create installment plan for down_payment.
*/
private static function createInstallmentPlan(int $memberId, int $paymentId, array $requestData): void
{
$db = App::getInstance()->db();
$payment = $db->selectOne("SELECT amount FROM payments WHERE id = ?", [$paymentId]);
if (!$payment) return;
$amount = (string) $payment['amount'];
$member = $db->selectOne("SELECT membership_value FROM members WHERE id = ?", [$memberId]);
if (!$member) return;
$membershipValue = $member['membership_value'] ?? '0.00';
$remaining = bcsub($membershipValue, $amount, 2);
if (bccomp($remaining, '0', 2) <= 0) return;
$months = min(30, max(1, (int) ($requestData['installment_months'] ?? 30)));
$interestRateData = \App\Modules\Rules\Services\RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$interestRate = $interestRateData['percentage'] ?? '22.00';
$totalInterest = bcdiv(bcmul($remaining, $interestRate, 4), '100', 2);
$totalWithInterest = bcadd($remaining, $totalInterest, 2);
$monthlyPayment = bcdiv($totalWithInterest, (string) $months, 2);
$planId = $db->insert('installment_plans', [
'member_id' => $memberId,
'related_entity_type'=> 'members',
'related_entity_id' => $memberId,
'total_amount' => $membershipValue,
'down_payment' => $amount,
'remaining_balance' => $remaining,
'interest_rate' => $interestRate,
'total_interest' => $totalInterest,
'total_with_interest'=> $totalWithInterest,
'number_of_months' => $months,
'monthly_payment' => $monthlyPayment,
'start_date' => date('Y-m-d'),
'status' => 'active',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$rem = $totalWithInterest;
for ($i = 1; $i <= $months; $i++) {
$principal = bcdiv($remaining, (string) $months, 2);
$interest = bcdiv($totalInterest, (string) $months, 2);
$instAmount = bcadd($principal, $interest, 2);
$rem = bcsub($rem, $instAmount, 2);
if (bccomp($rem, '0', 2) < 0) $rem = '0.00';
$db->insert('installment_schedule', [
'installment_plan_id' => $planId,
'installment_number' => $i,
'due_date' => date('Y-m-d', strtotime("+{$i} months")),
'amount' => $instAmount,
'principal' => $principal,
'interest' => $interest,
'remaining_after' => $rem,
'paid_amount' => '0.00',
'status' => 'pending',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
EventBus::dispatch('installment.plan_created', [
'plan_id' => $planId,
'member_id' => $memberId,
'total_amount' => $totalWithInterest,
]);
Logger::info("PaymentLifecycleService: installment plan created", ['plan_id' => $planId, 'member_id' => $memberId]);
}
}
...@@ -42,10 +42,14 @@ final class SpouseFeeCalculator ...@@ -42,10 +42,14 @@ final class SpouseFeeCalculator
$existingCount = Spouse::countActiveForMember($memberId); $existingCount = Spouse::countActiveForMember($memberId);
$spouseOrder = $existingCount + 1; $spouseOrder = $existingCount + 1;
$isInitialCreation = FormFeeService::isOnInitialForm($member); $isInitialCreation = $isOnInitialForm;
$isFreeSlot = FormFeeService::isSpouseFreeSlot($memberId, $spouseOrder); $isFreeSlot = FormFeeService::isSpouseFreeSlot($memberId, $spouseOrder);
$formFee = FormFeeService::getFormFee($memberId, $member); $formFeeOnly = FormFeeService::getFormFeeOnly($memberId, $member);
$annualSubscription = !$isOnInitialForm
? FormFeeService::getAnnualSubscriptionForAddition()
: '0.00';
$formFee = bcadd($formFeeOnly, $annualSubscription, 2);
$isAcquiredMember = self::isAcquiredMember($memberId); $isAcquiredMember = self::isAcquiredMember($memberId);
...@@ -153,14 +157,15 @@ final class SpouseFeeCalculator ...@@ -153,14 +157,15 @@ final class SpouseFeeCalculator
'year_count' => $yearCount, 'year_count' => $yearCount,
'yearly_total' => $yearlyTotal, 'yearly_total' => $yearlyTotal,
'addition_fee' => $additionFee, 'addition_fee' => $additionFee,
'form_fee' => $formFee, 'form_fee' => $formFeeOnly,
'annual_subscription' => $annualSubscription,
'total_fee' => $totalFee, 'total_fee' => $totalFee,
'rule_applied' => $ruleApplied, 'rule_applied' => $ruleApplied,
'error' => null, 'error' => null,
'breakdown' => self::buildBreakdown( 'breakdown' => self::buildBreakdown(
$spouseOrder, $membershipValue, $percentage, $percentageFee, $spouseOrder, $membershipValue, $percentage, $percentageFee,
$annualPerYear, $yearCount, $yearlyTotal, $annualPerYear, $yearCount, $yearlyTotal,
$formFee, $totalFee, $ruleApplied, $isFreeSlot && !$isForeign $formFeeOnly, $annualSubscription, $totalFee, $ruleApplied, $isFreeSlot && !$isForeign
), ),
]; ];
} }
...@@ -226,17 +231,22 @@ final class SpouseFeeCalculator ...@@ -226,17 +231,22 @@ final class SpouseFeeCalculator
private static function buildBreakdown( private static function buildBreakdown(
int $order, string $membershipValue, string $pct, string $pctFee, int $order, string $membershipValue, string $pct, string $pctFee,
string $annual, int $years, string $yearlyTotal, string $annual, int $years, string $yearlyTotal,
string $formFee, string $totalFee, string $rule, bool $isFirstFree string $formFee, string $annualSubscription, string $totalFee, string $rule, bool $isFirstFree
): array { ): array {
$lines = []; $lines = [];
$lines[] = '📋 القاعدة المطبقة: ' . $rule; $lines[] = '📋 القاعدة المطبقة: ' . $rule;
$lines[] = '💰 قيمة العضوية: ' . money($membershipValue); $lines[] = '💰 قيمة العضوية: ' . money($membershipValue);
if ($isFirstFree && bccomp($formFee, '0', 2) <= 0) { if ($isFirstFree && bccomp($formFee, '0', 2) <= 0 && bccomp($annualSubscription, '0', 2) <= 0) {
$lines[] = '✅ الزوجة الأولى مشمولة في الاستمارة — بدون رسوم إضافية'; $lines[] = '✅ الزوجة الأولى مشمولة في الاستمارة — بدون رسوم إضافية';
} elseif ($isFirstFree && bccomp($formFee, '0', 2) > 0) { } elseif ($isFirstFree) {
$lines[] = '✅ الزوجة الأولى مشمولة (بدون رسوم عضوية)'; $lines[] = '✅ الزوجة الأولى مشمولة (بدون رسوم عضوية)';
if (bccomp($formFee, '0', 2) > 0) {
$lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee); $lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
if (bccomp($annualSubscription, '0', 2) > 0) {
$lines[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
}
} else { } else {
if (bccomp($pctFee, '0', 2) > 0) { if (bccomp($pctFee, '0', 2) > 0) {
$lines[] = "📊 نسبة {$pct}% × " . money($membershipValue) . ' = ' . money($pctFee); $lines[] = "📊 نسبة {$pct}% × " . money($membershipValue) . ' = ' . money($pctFee);
...@@ -248,6 +258,9 @@ final class SpouseFeeCalculator ...@@ -248,6 +258,9 @@ final class SpouseFeeCalculator
if (bccomp($formFee, '0', 2) > 0) { if (bccomp($formFee, '0', 2) > 0) {
$lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee); $lines[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
} }
if (bccomp($annualSubscription, '0', 2) > 0) {
$lines[] = '📅 اشتراك سنوي + تنمية: ' . money($annualSubscription);
}
} }
$lines[] = '═══════════════════════════'; $lines[] = '═══════════════════════════';
...@@ -268,6 +281,7 @@ final class SpouseFeeCalculator ...@@ -268,6 +281,7 @@ final class SpouseFeeCalculator
'yearly_total' => '0.00', 'yearly_total' => '0.00',
'addition_fee' => '0.00', 'addition_fee' => '0.00',
'form_fee' => '0.00', 'form_fee' => '0.00',
'annual_subscription' => '0.00',
'total_fee' => '0.00', 'total_fee' => '0.00',
'rule_applied' => '', 'rule_applied' => '',
'error' => $message, 'error' => $message,
......
...@@ -63,7 +63,7 @@ class TransferController extends Controller ...@@ -63,7 +63,7 @@ class TransferController extends Controller
$notes = trim($request->post('notes', '')); $notes = trim($request->post('notes', ''));
$paymentMethod = trim($request->post('payment_method', 'cash')); $paymentMethod = trim($request->post('payment_method', 'cash'));
$validTypes = ['child_separation', 'child_mandatory_25', 'sports_conversion', 'cross_branch']; $validTypes = ['child_separation', 'child_mandatory_25', 'sports_conversion', 'cross_branch', 'full_transfer'];
if (!in_array($transferType, $validTypes)) { if (!in_array($transferType, $validTypes)) {
return $this->redirect("/transfers/create/{$memberId}")->withError('نوع التحويل غير صالح'); return $this->redirect("/transfers/create/{$memberId}")->withError('نوع التحويل غير صالح');
} }
...@@ -103,6 +103,7 @@ class TransferController extends Controller ...@@ -103,6 +103,7 @@ class TransferController extends Controller
// Companion validation: check if new owner brings extra dependents // Companion validation: check if new owner brings extra dependents
$targetSpousesCount = (int) $request->post('target_spouses_count', 0); $targetSpousesCount = (int) $request->post('target_spouses_count', 0);
$targetChildrenCount = (int) $request->post('target_children_count', 0); $targetChildrenCount = (int) $request->post('target_children_count', 0);
$targetChildrenAges = array_map('intval', $request->post('target_children_ages', []));
$companionSurcharge = '0.00'; $companionSurcharge = '0.00';
$companionBreakdown = null; $companionBreakdown = null;
$sourceCompanionsCount = null; $sourceCompanionsCount = null;
...@@ -112,7 +113,8 @@ class TransferController extends Controller ...@@ -112,7 +113,8 @@ class TransferController extends Controller
$surchargeResult = SeparationFeeCalculator::calculateCompanionSurcharge( $surchargeResult = SeparationFeeCalculator::calculateCompanionSurcharge(
(int) $memberId, (int) $memberId,
$targetSpousesCount, $targetSpousesCount,
$targetChildrenCount $targetChildrenCount,
$targetChildrenAges
); );
$companionSurcharge = $surchargeResult['surcharge']; $companionSurcharge = $surchargeResult['surcharge'];
$sourceCompanionsCount = $surchargeResult['source_count']; $sourceCompanionsCount = $surchargeResult['source_count'];
...@@ -124,8 +126,34 @@ class TransferController extends Controller ...@@ -124,8 +126,34 @@ class TransferController extends Controller
$totalWithSurcharge = bcadd($feeCalc['total_fee'], $companionSurcharge, 2); $totalWithSurcharge = bcadd($feeCalc['total_fee'], $companionSurcharge, 2);
// For full_transfer: store recipient data in notes JSON
$recipientData = null;
if ($transferType === 'full_transfer') {
$recipientFields = ['full_name_ar', 'full_name_en', 'national_id', 'date_of_birth', 'gender', 'nationality', 'qualification_id', 'phone_mobile', 'marital_status', 'religion', 'phone_home', 'email', 'emergency_name', 'emergency_phone', 'residence_address', 'area', 'governorate', 'occupation', 'job_title'];
$recipientData = [];
foreach ($recipientFields as $field) {
$val = trim($request->post('recipient_' . $field, ''));
if ($val !== '') {
$recipientData[$field] = $val;
}
}
if (empty($recipientData['full_name_ar']) || empty($recipientData['national_id'])) {
return $this->redirect("/transfers/create/{$memberId}")->withError('بيانات المستلم مطلوبة (الاسم والرقم القومي)');
}
}
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
// Build notes JSON with recipient data if full_transfer
$notesJson = null;
if ($transferType === 'full_transfer' && $recipientData) {
$notesPayload = ['recipient_data' => $recipientData];
if ($notes) $notesPayload['user_notes'] = $notes;
$notesJson = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
} else {
$notesJson = $notes ?: null;
}
$transferReq = TransferRequest::create([ $transferReq = TransferRequest::create([
'source_member_id' => (int) $memberId, 'source_member_id' => (int) $memberId,
'transfer_type' => $transferType, 'transfer_type' => $transferType,
...@@ -145,7 +173,7 @@ class TransferController extends Controller ...@@ -145,7 +173,7 @@ class TransferController extends Controller
'annual_subscription_fee' => $feeCalc['annual_subscription_fee'], 'annual_subscription_fee' => $feeCalc['annual_subscription_fee'],
'total_fee' => $totalWithSurcharge, 'total_fee' => $totalWithSurcharge,
'status' => 'requested', 'status' => 'requested',
'notes' => $notes ?: null, 'notes' => $notesJson,
]); ]);
if (FormBridge::exists('TRANSFER_SEPARATION')) { if (FormBridge::exists('TRANSFER_SEPARATION')) {
......
...@@ -124,12 +124,15 @@ final class SeparationFeeCalculator ...@@ -124,12 +124,15 @@ final class SeparationFeeCalculator
/** /**
* Calculate surcharge when new owner has more companions than the original member. * Calculate surcharge when new owner has more companions than the original member.
* Extra spouses/children are charged standard addition fees. * Uses the EXACT same tiered fee logic as adding a brand-new spouse/child:
* - Extra spouses are charged per their ordinal position (2nd/3rd/4th wife rules)
* - Extra children are charged per their ordinal position via PricingEngine
*/ */
public static function calculateCompanionSurcharge( public static function calculateCompanionSurcharge(
int $sourceMemberId, int $sourceMemberId,
int $targetSpousesCount, int $targetSpousesCount,
int $targetChildrenCount int $targetChildrenCount,
array $targetChildrenAges = []
): array { ): array {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]); $member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]);
...@@ -154,45 +157,96 @@ final class SeparationFeeCalculator ...@@ -154,45 +157,96 @@ final class SeparationFeeCalculator
$membershipValue = $member['membership_value'] ?? '0.00'; $membershipValue = $member['membership_value'] ?? '0.00';
// Extra spouses beyond what source had // Extra spouses: use tiered rules per ordinal position
$extraSpouses = max(0, $targetSpousesCount - $sourceSpouses); $extraSpouses = max(0, $targetSpousesCount - $sourceSpouses);
if ($extraSpouses > 0) { if ($extraSpouses > 0) {
$spouseBasePct = RuleEngine::getValue('SPOUSE_BASE_MEMBER_FEE', 'percentage') ?? '30.00'; for ($i = 0; $i < $extraSpouses; $i++) {
$perSpouseFee = bcdiv(bcmul($membershipValue, $spouseBasePct, 4), '100', 2); $spouseOrder = $sourceSpouses + $i + 1;
$spouseSurcharge = bcmul($perSpouseFee, (string) $extraSpouses, 2); $feeForOrder = self::getSpouseFeeByOrder($spouseOrder, $membershipValue);
$surcharge = bcadd($surcharge, $spouseSurcharge, 2); $surcharge = bcadd($surcharge, $feeForOrder['fee'], 2);
$breakdown[] = [ $breakdown[] = [
'type' => 'extra_spouses', 'type' => 'extra_spouse',
'count' => $extraSpouses, 'order' => $spouseOrder,
'per_unit' => $perSpouseFee, 'label' => $feeForOrder['label'],
'percentage' => $spouseBasePct, 'percentage' => $feeForOrder['percentage'],
'total' => $spouseSurcharge, 'fee' => $feeForOrder['fee'],
]; ];
} }
}
// Extra children beyond what source had // Extra children: use tiered rules per ordinal position and age
$extraChildren = max(0, $targetChildrenCount - $sourceChildren); $extraChildren = max(0, $targetChildrenCount - $sourceChildren);
if ($extraChildren > 0) { if ($extraChildren > 0) {
$childBasePct = RuleEngine::getValue('CHILD_ADDITION_FEE', 'percentage') ?? '10.00'; for ($i = 0; $i < $extraChildren; $i++) {
$perChildFee = bcdiv(bcmul($membershipValue, $childBasePct, 4), '100', 2); $childOrder = $sourceChildren + $i + 1;
$childSurcharge = bcmul($perChildFee, (string) $extraChildren, 2); $childAge = $targetChildrenAges[$i] ?? 10;
$surcharge = bcadd($surcharge, $childSurcharge, 2); $feeForOrder = self::getChildFeeByOrder($childOrder, $childAge, $membershipValue);
$surcharge = bcadd($surcharge, $feeForOrder['fee'], 2);
$breakdown[] = [ $breakdown[] = [
'type' => 'extra_children', 'type' => 'extra_child',
'count' => $extraChildren, 'order' => $childOrder,
'per_unit' => $perChildFee, 'age' => $childAge,
'percentage' => $childBasePct, 'label' => $feeForOrder['label'],
'total' => $childSurcharge, 'percentage' => $feeForOrder['percentage'],
'fee' => $feeForOrder['fee'],
]; ];
} }
}
return [ return [
'surcharge' => $surcharge, 'surcharge' => $surcharge,
'breakdown' => $breakdown, 'breakdown' => $breakdown,
'source_count' => $sourceTotal, 'source_count' => $sourceTotal,
'target_count' => $targetTotal, 'target_count' => $targetTotal,
'extra_spouses' => $extraSpouses ?? 0, 'extra_spouses' => $extraSpouses,
'extra_children' => $extraChildren ?? 0, 'extra_children' => $extraChildren,
]; ];
} }
/**
* Get the addition fee for a spouse at a specific ordinal position.
* Uses the same rule codes as SpouseFeeCalculator.
*/
private static function getSpouseFeeByOrder(int $order, string $membershipValue): array
{
switch (true) {
case ($order <= 1):
$data = RuleEngine::require('SPOUSE_BASE_MEMBER_FEE');
$pct = $data['percentage'];
$label = 'الزوجة الأولى — ' . $pct . '%';
break;
case ($order === 2):
$data = RuleEngine::require('SPOUSE_2ND_FEE');
$pct = $data['percentage'];
$label = 'الزوجة الثانية — ' . $pct . '%';
break;
case ($order === 3):
$data = RuleEngine::require('SPOUSE_3RD_FEE');
$pct = $data['percentage'];
$label = 'الزوجة الثالثة — ' . $pct . '%';
break;
default:
$data = RuleEngine::require('SPOUSE_4TH_FEE');
$pct = $data['percentage'];
$label = 'الزوجة الرابعة+ — ' . $pct . '%';
break;
}
$fee = bcdiv(bcmul($membershipValue, $pct, 4), '100', 2);
return ['fee' => $fee, 'percentage' => $pct, 'label' => $label];
}
/**
* Get the addition fee for a child at a specific ordinal position and age.
* Uses PricingEngine::calculateChildFee (same logic as ChildFeeCalculator).
*/
private static function getChildFeeByOrder(int $order, int $age, string $membershipValue): array
{
$feeResult = PricingEngine::calculateChildFee($membershipValue, $age, $order);
$fee = $feeResult['fee'] ?? '0.00';
$pct = $feeResult['percentage'] ?? '0.00';
$label = 'الابن/الابنة رقم ' . $order . ' (سن ' . $age . ') — ' . $pct . '%';
return ['fee' => $fee, 'percentage' => $pct, 'label' => $label];
}
} }
\ No newline at end of file
...@@ -15,10 +15,10 @@ final class TransferProcessor ...@@ -15,10 +15,10 @@ final class TransferProcessor
/** /**
* Execute a complete transfer/separation. * Execute a complete transfer/separation.
* 1. Take archive snapshot * 1. Take archive snapshot
* 2. Create new member record * 2. Archive source member FIRST (releases membership_number)
* 3. Assign new membership number * 3. Create new member record with SAME number
* 4. Record number chain * 4. Record number chain
* 5. Update source records * 5. Transfer dependents / mark separated
* 6. Mark transfer complete * 6. Mark transfer complete
*/ */
public static function execute(int $transferRequestId): array public static function execute(int $transferRequestId): array
...@@ -55,6 +55,7 @@ final class TransferProcessor ...@@ -55,6 +55,7 @@ final class TransferProcessor
// 2. Determine the subject of transfer // 2. Determine the subject of transfer
$subjectName = $sourceMember['full_name_ar']; $subjectName = $sourceMember['full_name_ar'];
$subjectData = $sourceMember; $subjectData = $sourceMember;
$isFullTransfer = !$request['child_id'] && !$request['spouse_id'];
if ($request['child_id']) { if ($request['child_id']) {
$child = $db->selectOne("SELECT * FROM children WHERE id = ?", [(int) $request['child_id']]); $child = $db->selectOne("SELECT * FROM children WHERE id = ?", [(int) $request['child_id']]);
...@@ -70,57 +71,76 @@ final class TransferProcessor ...@@ -70,57 +71,76 @@ final class TransferProcessor
} }
} }
// 3. Create new member record // For full_transfer type, use recipient data stored in notes
$recipientData = [];
if ($request['transfer_type'] === 'full_transfer' && !empty($request['notes'])) {
$notesDecoded = json_decode($request['notes'], true);
$recipientData = $notesDecoded['recipient_data'] ?? [];
}
$sameNumber = $sourceMember['membership_number'];
// Find the separation_fee payment that was made for this transfer
$transferPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'separation_fee' AND related_entity_type = 'transfer_requests' AND related_entity_id = ? AND is_voided = 0 ORDER BY id DESC LIMIT 1",
[(int) $request['source_member_id'], $transferRequestId]
);
$transferPaymentId = $transferPayment ? (int) $transferPayment['id'] : null;
// 3. Archive source member FIRST — releases the unique membership_number
$db->update('members', [
'membership_number' => null,
'status' => 'transferred',
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $sourceMember['id']]);
// 4. Create new member record with SAME membership number
$newMemberData = [ $newMemberData = [
'full_name_ar' => $subjectData['full_name_ar'] ?? $subjectName, 'full_name_ar' => $recipientData['full_name_ar'] ?? $subjectData['full_name_ar'] ?? $subjectName,
'full_name_en' => $subjectData['full_name_en'] ?? null, 'full_name_en' => $recipientData['full_name_en'] ?? $subjectData['full_name_en'] ?? null,
'national_id' => $subjectData['national_id'] ?? null, 'national_id' => $recipientData['national_id'] ?? $subjectData['national_id'] ?? null,
'passport_number' => $subjectData['passport_number'] ?? null, 'passport_number' => $subjectData['passport_number'] ?? null,
'id_type' => $subjectData['id_type'] ?? 'national_id', 'id_type' => $subjectData['id_type'] ?? 'national_id',
'date_of_birth' => $subjectData['date_of_birth'], 'date_of_birth' => $recipientData['date_of_birth'] ?? $subjectData['date_of_birth'],
'age_years' => $subjectData['age_years'] ?? null, 'gender' => $recipientData['gender'] ?? $subjectData['gender'],
'age_months' => $subjectData['age_months'] ?? null, 'nationality' => $recipientData['nationality'] ?? $subjectData['nationality'] ?? 'مصري',
'gender' => $subjectData['gender'],
'nationality' => $subjectData['nationality'] ?? 'مصري',
'branch_id' => (int) $sourceMember['branch_id'], 'branch_id' => (int) $sourceMember['branch_id'],
'membership_type' => 'working', 'membership_type' => 'working',
'member_category' => 'working_member', 'member_category' => 'working_member',
'status' => 'active', 'status' => 'active',
'qualification_id' => $subjectData['qualification_id'] ?? $sourceMember['qualification_id'], 'activated_at' => date('Y-m-d H:i:s'),
'phone_mobile' => $subjectData['mobile'] ?? $subjectData['phone_mobile'] ?? $sourceMember['phone_mobile'], 'activated_by_payment_id'=> $transferPaymentId,
'qualification_id' => $recipientData['qualification_id'] ?? $subjectData['qualification_id'] ?? $sourceMember['qualification_id'],
'phone_mobile' => $recipientData['phone_mobile'] ?? $subjectData['mobile'] ?? $subjectData['phone_mobile'] ?? $sourceMember['phone_mobile'],
'membership_value' => $request['new_membership_value'], 'membership_value' => $request['new_membership_value'],
'membership_number' => $sameNumber,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]; ];
// Transfer the SAME membership number to the new member // Add optional recipient fields for full_transfer
$sameNumber = $sourceMember['membership_number']; $optionalFields = ['marital_status', 'religion', 'phone_home', 'email', 'emergency_name', 'emergency_phone', 'residence_address', 'area', 'governorate', 'occupation', 'job_title'];
$newMemberData['membership_number'] = $sameNumber; foreach ($optionalFields as $field) {
if (!empty($recipientData[$field])) {
$newMemberData[$field] = $recipientData[$field];
}
}
$newMemberId = $db->insert('members', $newMemberData); $newMemberId = $db->insert('members', $newMemberData);
// 4. Clear membership number from source and archive
$db->update('members', [
'membership_number' => null,
'status' => 'transferred',
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $sourceMember['id']]);
$newNumber = $sameNumber;
// 5. Record number chain // 5. Record number chain
ArchiveService::recordNumberTransfer( ArchiveService::recordNumberTransfer(
$newNumber, $sameNumber,
$request['transfer_type'], $request['transfer_type'],
'members', 'members',
$newMemberId, $newMemberId,
null null
); );
// 6. Update source dependents — mark as separated if it's a child/spouse separation // 6. Update source dependents
if ($request['child_id']) { if ($request['child_id']) {
$db->update('children', [ $db->update('children', [
'status' => 'separated', 'status' => 'separated',
...@@ -134,8 +154,8 @@ final class TransferProcessor ...@@ -134,8 +154,8 @@ final class TransferProcessor
], '`id` = ?', [(int) $request['spouse_id']]); ], '`id` = ?', [(int) $request['spouse_id']]);
} }
// Transfer all dependents from source to new member (full membership transfer) // Full membership transfer: move all dependents to new member
if (!$request['child_id'] && !$request['spouse_id']) { if ($isFullTransfer) {
$db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]); $db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
$db->query("UPDATE children SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]); $db->query("UPDATE children SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
$db->query("UPDATE temporary_members SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]); $db->query("UPDATE temporary_members SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
...@@ -144,7 +164,7 @@ final class TransferProcessor ...@@ -144,7 +164,7 @@ final class TransferProcessor
// 7. Mark transfer request complete // 7. Mark transfer request complete
$db->update('transfer_requests', [ $db->update('transfer_requests', [
'target_member_id' => $newMemberId, 'target_member_id' => $newMemberId,
'new_membership_number'=> $newNumber, 'new_membership_number'=> $sameNumber,
'archive_snapshot_id' => $snapshotId, 'archive_snapshot_id' => $snapshotId,
'completed_at' => date('Y-m-d H:i:s'), 'completed_at' => date('Y-m-d H:i:s'),
'status' => 'completed', 'status' => 'completed',
...@@ -158,7 +178,7 @@ final class TransferProcessor ...@@ -158,7 +178,7 @@ final class TransferProcessor
'transfer_id' => $transferRequestId, 'transfer_id' => $transferRequestId,
'source_member_id' => (int) $request['source_member_id'], 'source_member_id' => (int) $request['source_member_id'],
'target_member_id' => $newMemberId, 'target_member_id' => $newMemberId,
'new_number' => $newNumber, 'new_number' => $sameNumber,
'transfer_type' => $request['transfer_type'], 'transfer_type' => $request['transfer_type'],
'fee_amount' => $request['total_fee'] ?? $request['separation_fee'] ?? '0.00', 'fee_amount' => $request['total_fee'] ?? $request['separation_fee'] ?? '0.00',
'old_number' => $sourceMember['membership_number'] ?? null, 'old_number' => $sourceMember['membership_number'] ?? null,
...@@ -167,13 +187,13 @@ final class TransferProcessor ...@@ -167,13 +187,13 @@ final class TransferProcessor
Logger::info("Transfer completed", [ Logger::info("Transfer completed", [
'transfer_id' => $transferRequestId, 'transfer_id' => $transferRequestId,
'new_member' => $newMemberId, 'new_member' => $newMemberId,
'new_number' => $newNumber, 'new_number' => $sameNumber,
]); ]);
return [ return [
'success' => true, 'success' => true,
'new_member_id' => $newMemberId, 'new_member_id' => $newMemberId,
'new_number' => $newNumber, 'new_number' => $sameNumber,
'snapshot_id' => $snapshotId, 'snapshot_id' => $snapshotId,
'transfer_id' => $transferRequestId, 'transfer_id' => $transferRequestId,
]; ];
......
This diff is collapsed.
...@@ -2,11 +2,21 @@ ...@@ -2,11 +2,21 @@
<?php $__template->section('title'); ?>طلب تحويل #<?= (int) $transfer['id'] ?><?php $__template->endSection(); ?> <?php $__template->section('title'); ?>طلب تحويل #<?= (int) $transfer['id'] ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php <?php
$typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' => 'تحويل وجوبي (25 سنة)', 'sports_conversion' => 'تحويل رياضي لعامل', 'cross_branch' => 'تحويل بين فروع']; $typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' => 'تحويل وجوبي (25 سنة)', 'sports_conversion' => 'تحويل رياضي لعامل', 'cross_branch' => 'تحويل بين فروع', 'full_transfer' => 'تحويل عضوية كامل'];
$recipientData = null;
if ($transfer['transfer_type'] === 'full_transfer' && !empty($transfer['notes'])) {
$notesDecoded = json_decode($transfer['notes'], true);
$recipientData = $notesDecoded['recipient_data'] ?? null;
}
?> ?>
<div class="card" style="padding:20px;margin-bottom:20px;"> <div class="card" style="padding:20px;margin-bottom:20px;">
<table style="width:100%;max-width:700px;font-size:14px;"> <table style="width:100%;max-width:700px;font-size:14px;">
<tr><td style="padding:8px 0;color:#6B7280;width:35%;">العضو المصدر</td><td style="padding:8px 0;"><a href="/members/<?= (int) $transfer['source_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($transfer['source_name'] ?? '') ?></a></td></tr> <tr><td style="padding:8px 0;color:#6B7280;width:35%;">العضو المصدر</td><td style="padding:8px 0;"><a href="/members/<?= (int) $transfer['source_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($transfer['source_name'] ?? '') ?></a> (<?= e($transfer['source_number'] ?? '—') ?>)</td></tr>
<?php if ($transfer['target_member_id']): ?>
<tr><td style="padding:8px 0;color:#6B7280;">المستلم</td><td style="padding:8px 0;"><a href="/members/<?= (int) $transfer['target_member_id'] ?>" style="color:#0D7377;font-weight:700;font-size:16px;"><?= e($transfer['target_name'] ?? '') ?></a></td></tr>
<?php elseif ($recipientData): ?>
<tr><td style="padding:8px 0;color:#6B7280;">المستلم (بيانات)</td><td style="padding:8px 0;font-weight:600;"><?= e($recipientData['full_name_ar'] ?? '—') ?><?= e($recipientData['national_id'] ?? '') ?></td></tr>
<?php endif; ?>
<?php if (!empty($transfer['child_name'])): ?> <?php if (!empty($transfer['child_name'])): ?>
<tr><td style="padding:8px 0;color:#6B7280;">الابن/الابنة</td><td style="padding:8px 0;font-weight:600;"><?= e($transfer['child_name']) ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">الابن/الابنة</td><td style="padding:8px 0;font-weight:600;"><?= e($transfer['child_name']) ?></td></tr>
<?php endif; ?> <?php endif; ?>
...@@ -15,7 +25,13 @@ $typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' = ...@@ -15,7 +25,13 @@ $typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' =
<?php endif; ?> <?php endif; ?>
<tr><td style="padding:8px 0;color:#6B7280;">النوع</td><td style="padding:8px 0;font-weight:600;"><?= e($typeLabels[$transfer['transfer_type']] ?? $transfer['transfer_type']) ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">النوع</td><td style="padding:8px 0;font-weight:600;"><?= e($typeLabels[$transfer['transfer_type']] ?? $transfer['transfer_type']) ?></td></tr>
<tr><td style="padding:8px 0;color:#6B7280;">الحالة</td><td style="padding:8px 0;font-weight:700;color:<?= match($transfer['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= match($transfer['status']) { 'completed' => 'مكتمل', 'fee_paid' => 'تم الدفع', 'approved' => 'معتمد', 'rejected' => 'مرفوض', default => 'مقدّم' } ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">الحالة</td><td style="padding:8px 0;font-weight:700;color:<?= match($transfer['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= match($transfer['status']) { 'completed' => 'مكتمل', 'fee_paid' => 'تم الدفع', 'approved' => 'معتمد', 'rejected' => 'مرفوض', default => 'مقدّم' } ?></td></tr>
<?php if ($transfer['new_membership_number']): ?><tr><td style="padding:8px 0;color:#6B7280;">الرقم الجديد</td><td style="padding:8px 0;font-weight:700;color:#0D7377;font-size:20px;"><?= e($transfer['new_membership_number']) ?></td></tr><?php endif; ?> <?php if ($transfer['new_membership_number']): ?><tr><td style="padding:8px 0;color:#6B7280;">رقم العضوية (نفس الرقم)</td><td style="padding:8px 0;font-weight:700;color:#0D7377;font-size:20px;"><?= e($transfer['new_membership_number']) ?></td></tr><?php endif; ?>
<?php if ($transfer['source_companions_count'] !== null): ?>
<tr><td style="padding:8px 0;color:#6B7280;">ملحقين العضوية الأصلية</td><td style="padding:8px 0;"><?= (int) $transfer['source_companions_count'] ?></td></tr>
<?php endif; ?>
<?php if ($transfer['target_companions_count'] !== null): ?>
<tr><td style="padding:8px 0;color:#6B7280;">ملحقين المستلم</td><td style="padding:8px 0;"><?= (int) $transfer['target_companions_count'] ?></td></tr>
<?php endif; ?>
</table> </table>
</div> </div>
...@@ -57,6 +73,24 @@ $typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' = ...@@ -57,6 +73,24 @@ $typeLabels = ['child_separation' => 'فصل أبناء', 'child_mandatory_25' =
<td style="padding:8px 0;color:#6B7280;">اشتراك سنوي + تنمية</td> <td style="padding:8px 0;color:#6B7280;">اشتراك سنوي + تنمية</td>
<td style="padding:8px 0;font-weight:600;direction:ltr;text-align:left;"><?= money($transfer['annual_subscription_fee'] ?? '0') ?></td> <td style="padding:8px 0;font-weight:600;direction:ltr;text-align:left;"><?= money($transfer['annual_subscription_fee'] ?? '0') ?></td>
</tr> </tr>
<?php if (bccomp($transfer['companion_surcharge'] ?? '0', '0', 2) > 0): ?>
<tr>
<td style="padding:8px 0;color:#7C3AED;font-weight:600;">رسوم ملحقين إضافيين</td>
<td style="padding:8px 0;font-weight:700;direction:ltr;text-align:left;color:#7C3AED;"><?= money($transfer['companion_surcharge']) ?></td>
</tr>
<?php
$csBreakdown = !empty($transfer['companion_surcharge_breakdown']) ? json_decode($transfer['companion_surcharge_breakdown'], true) : [];
if (is_array($csBreakdown) && !empty($csBreakdown)):
?>
<tr><td colspan="2" style="padding:4px 0;">
<div style="font-size:12px;color:#6B7280;background:#F5F3FF;border:1px solid #DDD6FE;border-radius:6px;padding:8px 12px;margin-top:4px;">
<?php foreach ($csBreakdown as $item): ?>
<div style="padding:2px 0;"><?= e($item['label'] ?? ($item['type'] ?? '')) ?>: <?= money($item['fee'] ?? $item['total'] ?? '0') ?></div>
<?php endforeach; ?>
</div>
</td></tr>
<?php endif; ?>
<?php endif; ?>
<tr style="border-top:2px solid #0D7377;"> <tr style="border-top:2px solid #0D7377;">
<td style="padding:12px 0;font-weight:700;font-size:16px;">الإجمالي المطلوب</td> <td style="padding:12px 0;font-weight:700;font-size:16px;">الإجمالي المطلوب</td>
<td style="padding:12px 0;font-weight:800;font-size:22px;color:#DC2626;direction:ltr;text-align:left;"><?= money($transfer['total_fee'] ?? '0') ?></td> <td style="padding:12px 0;font-weight:800;font-size:22px;color:#DC2626;direction:ltr;text-align:left;"><?= money($transfer['total_fee'] ?? '0') ?></td>
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `members`
ADD COLUMN `activated_by_payment_id` BIGINT UNSIGNED NULL AFTER `membership_number`,
ADD COLUMN `activated_at` DATETIME NULL AFTER `activated_by_payment_id`;
ALTER TABLE `spouses`
ADD COLUMN `activated_by_payment_id` BIGINT UNSIGNED NULL AFTER `status`,
ADD COLUMN `fee_receipt_number` VARCHAR(50) NULL AFTER `activated_by_payment_id`;
ALTER TABLE `children`
ADD COLUMN `activated_by_payment_id` BIGINT UNSIGNED NULL AFTER `status`,
ADD COLUMN `fee_receipt_number` VARCHAR(50) NULL AFTER `activated_by_payment_id`;
ALTER TABLE `temporary_members`
ADD COLUMN `activated_by_payment_id` BIGINT UNSIGNED NULL AFTER `status`,
ADD COLUMN `fee_receipt_number` VARCHAR(50) NULL AFTER `activated_by_payment_id`
",
'down' => "
ALTER TABLE `members`
DROP COLUMN `activated_by_payment_id`,
DROP COLUMN `activated_at`;
ALTER TABLE `spouses`
DROP COLUMN `activated_by_payment_id`,
DROP COLUMN `fee_receipt_number`;
ALTER TABLE `children`
DROP COLUMN `activated_by_payment_id`,
DROP COLUMN `fee_receipt_number`;
ALTER TABLE `temporary_members`
DROP COLUMN `activated_by_payment_id`,
DROP COLUMN `fee_receipt_number`
",
];
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