Commit e4340b36 authored by Fares's avatar Fares

feat(death,waiver): enhance death module with 6 changes; fix waiver post-completion issues

Death module:
- Annual subscription now includes ALL family members (spouses, children, temps + dev fee)
- Temporary members distribution UI added to create form
- Payment receipt created in primary spouse's name (not deceased)
- Form number field added to membership application
- Deceased member name stored on new membership record
- Full Arabic audit trail logging on completion

Waiver module:
- Mark ALL family subscriptions as paid after completion (not just member)
- Set activated_by_payment_id on all dependents
- Copy membership_value from source to target member
- Add transferred_from_waiver_id and waived_from_member_name to members
- Display waiver source badge and archive section on member profile
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 9a8823b4
......@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Death\Models\DeathCase;
use App\Modules\Archive\Services\ArchiveService;
use App\Modules\Rules\Services\RuleEngine;
......@@ -29,6 +30,51 @@ class DeathController extends Controller
return compact('formFee', 'annualSubBase', 'devFee', 'annualSub', 'totalFee');
}
/**
* Calculate full annual subscription for all family members of a membership.
*/
private static function calculateFamilyAnnualSub(int $memberId): array
{
$db = App::getInstance()->db();
$memberSub = ServicePrice::getPrice('SVC_ANNUAL_MEMBER', '492.00');
$spouseSub = ServicePrice::getPrice('SVC_ANNUAL_SPOUSE', '492.00');
$childSub = ServicePrice::getPrice('SVC_ANNUAL_CHILD', '222.00');
$tempSub = ServicePrice::getPrice('SVC_ANNUAL_TEMP', '222.00');
$devFeeData = RuleEngine::get('DEVELOPMENT_FEE');
$devFee = $devFeeData['amount'] ?? '35.00';
$spouseCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$memberId]
)['cnt'] ?? 0);
$childCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$memberId]
)['cnt'] ?? 0);
$tempCount = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$memberId]
)['cnt'] ?? 0);
$familyTotal = $memberSub;
$familyTotal = bcadd($familyTotal, bcmul($spouseSub, (string) $spouseCount, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($childSub, (string) $childCount, 2), 2);
$familyTotal = bcadd($familyTotal, bcmul($tempSub, (string) $tempCount, 2), 2);
$annualSubTotal = bcadd($familyTotal, $devFee, 2);
return [
'annual_sub_total' => $annualSubTotal,
'member_sub' => $memberSub,
'spouse_sub' => $spouseSub,
'child_sub' => $childSub,
'temp_sub' => $tempSub,
'dev_fee' => $devFee,
'spouse_count' => $spouseCount,
'child_count' => $childCount,
'temp_count' => $tempCount,
];
}
private static function uploadDocument(array $fileInfo, string $prefix, int $memberId): array
{
$allowedMimes = ['application/pdf', 'image/jpeg', 'image/png', 'image/gif'];
......@@ -88,15 +134,19 @@ class DeathController extends Controller
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active' ORDER BY spouse_order", [(int) $memberId]);
$children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $memberId]);
$temps = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active' ORDER BY id", [(int) $memberId]);
$fees = self::getFees();
$familySub = self::calculateFamilyAnnualSub((int) $memberId);
return $this->view('Death.Views.create', [
'member' => $member,
'spouses' => $spouses,
'children' => $children,
'temps' => $temps,
'form_fee' => $fees['formFee'],
'annual_sub' => $fees['annualSub'],
'total_fee' => $fees['totalFee'],
'annual_sub' => $familySub['annual_sub_total'],
'total_fee' => bcadd($fees['formFee'], $familySub['annual_sub_total'], 2),
'family_sub' => $familySub,
]);
}
......@@ -115,7 +165,6 @@ class DeathController extends Controller
return $this->redirect("/death/create/{$memberId}")->withError('بيانات الوفاة غير مكتملة');
}
// Mandatory document upload only for primary_member death
$certPath = null;
$inheritancePath = null;
if ($deceasedType === 'primary_member') {
......@@ -140,10 +189,10 @@ class DeathController extends Controller
$inheritancePath = $inheritanceUpload['path'];
}
$fees = self::getFees();
$totalFee = $fees['totalFee'];
$fees = self::getFees();
$familySub = self::calculateFamilyAnnualSub((int) $memberId);
$totalFee = bcadd($fees['formFee'], $familySub['annual_sub_total'], 2);
// Status: primary_member → board_review; spouse/child → recorded (no fee, no board)
$initialStatus = ($deceasedType === 'primary_member') ? 'board_review' : 'recorded';
$caseData = [
......@@ -160,9 +209,10 @@ class DeathController extends Controller
];
if ($deceasedType === 'primary_member') {
$primarySpouseId = $request->post('primary_spouse_id') ? (int) $request->post('primary_spouse_id') : null;
$primarySpouseId = $request->post('primary_spouse_id') ? (int) $request->post('primary_spouse_id') : null;
$secondarySpouseIds = $request->post('secondary_spouse_ids', []);
$childrenAssignment = $request->post('children_assignment', []);
$tempsAssignment = $request->post('temps_assignment', []);
if (!$primarySpouseId) {
return $this->redirect("/death/create/{$memberId}")->withError('يجب اختيار الزوجة الأساسية لنقل العضوية');
......@@ -173,9 +223,17 @@ class DeathController extends Controller
$caseData['secondary_spouses_json'] = !empty($secondarySpouseIds) ? json_encode(array_map('intval', $secondarySpouseIds)) : null;
$caseData['children_assignment_json'] = !empty($childrenAssignment) ? json_encode($childrenAssignment) : null;
// Store temps assignment in notes JSON
$notesPayload = [];
if ($notes) $notesPayload['user_notes'] = $notes;
if (!empty($tempsAssignment)) $notesPayload['temps_assignment'] = $tempsAssignment;
if (!empty($notesPayload)) {
$caseData['notes'] = json_encode($notesPayload, JSON_UNESCAPED_UNICODE);
}
$secondaryCount = count($secondarySpouseIds);
if ($secondaryCount > 0) {
$totalFee = bcmul($fees['totalFee'], (string) (1 + $secondaryCount), 2);
$totalFee = bcmul($totalFee, (string) (1 + $secondaryCount), 2);
$caseData['fee_amount'] = $totalFee;
}
} elseif ($deceasedType === 'spouse') {
......@@ -196,7 +254,6 @@ class DeathController extends Controller
return $this->redirect("/death/{$case->id}")->withSuccess('تم تسجيل حالة الوفاة وإرسالها لمراجعة مجلس الإدارة');
}
// Non-primary: spouse/child death needs no payment, can complete directly
return $this->redirect("/death/{$case->id}")->withSuccess('تم تسجيل حالة الوفاة');
}
......@@ -211,6 +268,7 @@ class DeathController extends Controller
if (!$case) return $this->redirect('/death')->withError('الحالة غير موجودة');
$fees = self::getFees();
$familySub = self::calculateFamilyAnnualSub((int) $case['member_id']);
$primarySpouse = null;
$secondarySpouses = [];
$newMember = null;
......@@ -240,6 +298,7 @@ class DeathController extends Controller
return $this->view('Death.Views.show', [
'case' => $case,
'fees' => $fees,
'familySub' => $familySub,
'primarySpouse' => $primarySpouse,
'secondarySpouses' => $secondarySpouses,
'newMember' => $newMember,
......@@ -273,11 +332,8 @@ class DeathController extends Controller
return $this->redirect("/death/{$id}")->withError('قيمة رسوم مجلس الأمناء غير صحيحة');
}
// Calculate trustee fee (once regardless of secondary spouse count)
$boardFeeAmount = '0.00';
if ($feeMethod === 'percentage') {
$membershipValue = (string) ($case['member_membership_value'] ?? '0');
// Need to fetch membership_value from members table directly
$memberRow = $db->selectOne("SELECT membership_value FROM members WHERE id = ?", [(int) $case['member_id']]);
$membershipValue = (string) ($memberRow['membership_value'] ?? '0');
$boardFeeAmount = bcdiv(bcmul($membershipValue, $feeValue, 6), '100', 2);
......@@ -285,13 +341,19 @@ class DeathController extends Controller
$boardFeeAmount = bcadd($feeValue, '0', 2);
}
// Recalculate total: base fees × spouse multiplier + trustee fee (flat)
// Calculate family annual subscription
$fees = self::getFees();
$familySub = self::calculateFamilyAnnualSub((int) $case['member_id']);
$annualSubTotal = $familySub['annual_sub_total'];
$secondaryCount = 0;
if (!empty($case['secondary_spouses_json'])) {
$secondaryCount = count(json_decode($case['secondary_spouses_json'], true));
}
$baseFees = bcmul($fees['totalFee'], (string) (1 + $secondaryCount), 2);
// Base fees per membership = form_fee + family annual subscription
$basePerMembership = bcadd($fees['formFee'], $annualSubTotal, 2);
$baseFees = bcmul($basePerMembership, (string) (1 + $secondaryCount), 2);
$totalFee = bcadd($baseFees, $boardFeeAmount, 2);
$db->update('death_cases', [
......@@ -308,15 +370,26 @@ class DeathController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
// Build fee breakdown for cashier notes
$multiplierLabel = $secondaryCount > 0 ? " (× " . (1 + $secondaryCount) . " زوجات)" : '';
// Build fee breakdown
$multiplierLabel = $secondaryCount > 0 ? " (× " . (1 + $secondaryCount) . " عضويات)" : '';
$breakdown = [
'رسوم نقل العضوية' . $multiplierLabel . ':',
' رسوم استمارة: ' . money($fees['formFee']),
' اشتراك سنوي: ' . money($fees['annualSub']),
' اشتراك سنوي (جميع الأفراد): ' . money($annualSubTotal),
' - العضو: ' . money($familySub['member_sub']),
];
if ($familySub['spouse_count'] > 0) {
$breakdown[] = ' - ' . $familySub['spouse_count'] . ' زوجة: ' . money(bcmul($familySub['spouse_sub'], (string) $familySub['spouse_count'], 2));
}
if ($familySub['child_count'] > 0) {
$breakdown[] = ' - ' . $familySub['child_count'] . ' أبناء: ' . money(bcmul($familySub['child_sub'], (string) $familySub['child_count'], 2));
}
if ($familySub['temp_count'] > 0) {
$breakdown[] = ' - ' . $familySub['temp_count'] . ' مؤقتين: ' . money(bcmul($familySub['temp_sub'], (string) $familySub['temp_count'], 2));
}
$breakdown[] = ' - رسوم تنمية: ' . money($familySub['dev_fee']);
if ($secondaryCount > 0) {
$breakdown[] = ' مجموع الأساس: ' . money($baseFees);
$breakdown[] = ' مجموع الأساس (' . (1 + $secondaryCount) . ' عضويات): ' . money($baseFees);
}
$trusteeLabel = $feeMethod === 'percentage'
? 'رسوم مجلس الأمناء (' . $feeValue . '% من قيمة العضوية)'
......@@ -325,21 +398,26 @@ class DeathController extends Controller
$breakdown[] = '═══════════════════════════';
$breakdown[] = 'الإجمالي: ' . money($totalFee);
// Payment request uses the PRIMARY SPOUSE name (the person receiving the membership)
$primarySpouse = $db->selectOne("SELECT full_name_ar FROM spouses WHERE id = ?", [(int) $case['primary_spouse_id']]);
$recipientName = $primarySpouse['full_name_ar'] ?? 'الزوجة';
$result = PaymentRequestService::createRequest([
'member_id' => (int) $case['member_id'],
'amount' => $totalFee,
'payment_type' => 'death_fee',
'related_entity_type' => 'death_cases',
'related_entity_id' => (int) $id,
'description_ar' => 'رسوم وفاة — نقل عضوية — حالة #' . $id,
'description_ar' => 'رسوم نقل عضوية (وفاة) — باسم: ' . $recipientName . ' — حالة #' . $id,
'notes' => json_encode([
'fee_breakdown' => $breakdown,
'form_fee' => $fees['formFee'],
'annual_sub' => $fees['annualSub'],
'trustee_fee' => $boardFeeAmount,
'trustee_method' => $feeMethod,
'multiplier' => 1 + $secondaryCount,
'total' => $totalFee,
'fee_breakdown' => $breakdown,
'recipient_name' => $recipientName,
'form_fee' => $fees['formFee'],
'annual_sub' => $annualSubTotal,
'trustee_fee' => $boardFeeAmount,
'trustee_method' => $feeMethod,
'multiplier' => 1 + $secondaryCount,
'total' => $totalFee,
], JSON_UNESCAPED_UNICODE),
]);
......@@ -508,9 +586,14 @@ class DeathController extends Controller
}
}
// Merge with existing notes (temps_assignment etc)
$existingNotes = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (!is_array($existingNotes)) $existingNotes = [];
$existingNotes['form_data'] = $data;
$db->update('death_cases', [
'primary_spouse_form_filled' => 1,
'notes' => json_encode(['form_data' => $data], JSON_UNESCAPED_UNICODE),
'notes' => json_encode($existingNotes, JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
......@@ -525,7 +608,6 @@ class DeathController extends Controller
$employee = App::getInstance()->currentEmployee();
// Pre-completion validations for primary_member death
if ($case['deceased_type'] === 'primary_member') {
if (!$case['board_approved_at']) {
return $this->redirect("/death/{$id}")->withError('لم يتم اعتماد القضية من مجلس الإدارة بعد');
......@@ -549,30 +631,37 @@ class DeathController extends Controller
$db->beginTransaction();
try {
$snapshotId = ArchiveService::takeSnapshot('members', (int) $case['member_id'], 'death', 'وفاة — حالة #' . $id);
$auditLog = [];
if ($case['deceased_type'] === 'spouse' && $case['spouse_id']) {
$spouseRow = $db->selectOne("SELECT full_name_ar FROM spouses WHERE id = ?", [(int) $case['spouse_id']]);
$db->update('spouses', [
'status' => 'deceased', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['spouse_id']]);
$auditLog[] = 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? '');
} elseif ($case['deceased_type'] === 'child' && $case['child_id']) {
$childRow = $db->selectOne("SELECT full_name_ar FROM children WHERE id = ?", [(int) $case['child_id']]);
$db->update('children', [
'status' => 'deceased', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['child_id']]);
$auditLog[] = 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? '');
} elseif ($case['deceased_type'] === 'primary_member') {
$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']]);
$formData = [];
if (!empty($case['notes'])) {
$notesData = json_decode($case['notes'], true);
$formData = $notesData['form_data'] ?? [];
$notesData = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (is_array($notesData)) {
$formData = $notesData['form_data'] ?? [];
}
$tempsAssignment = $notesData['temps_assignment'] ?? [];
$inheritedNumber = $member['membership_number'];
$auditLog[] = 'العضو المتوفى: ' . $member['full_name_ar'] . ' — رقم العضوية: ' . $inheritedNumber;
$deathPayment = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type = 'death_fee'
......@@ -582,7 +671,7 @@ class DeathController extends Controller
);
$deathPaymentId = $deathPayment ? (int) $deathPayment['id'] : null;
// STEP 1: Archive the deceased member and release their membership_number
// STEP 1: Archive deceased member
$db->update('members', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
......@@ -590,14 +679,14 @@ class DeathController extends Controller
'membership_number' => null,
], '`id` = ?', [(int) $case['member_id']]);
// STEP 2: Insert new primary member with inherited number + source tracking
// STEP 2: Create new primary member
$newMemberData = [
'membership_number' => $inheritedNumber,
'full_name_ar' => $formData['full_name_ar'] ?? $spouse['full_name_ar'],
'full_name_en' => $formData['full_name_en'] ?? $spouse['full_name_en'] ?? null,
'national_id' => $formData['national_id'] ?? $spouse['national_id'],
'date_of_birth' => $formData['date_of_birth'] ?? $spouse['date_of_birth'],
'gender' => $formData['gender'] ?? $spouse['gender'],
'date_of_birth' => $formData['date_of_birth'] ?? $spouse['date_of_birth'] ?? '1900-01-01',
'gender' => $formData['gender'] ?? $spouse['gender'] ?? 'female',
'nationality' => $formData['nationality'] ?? $spouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'],
'membership_type' => $member['membership_type'] ?? 'working',
......@@ -610,6 +699,8 @@ class DeathController extends Controller
'membership_value' => $member['membership_value'],
'transferred_from_death_id' => (int) $id,
'original_membership_number' => $inheritedNumber,
'form_number' => $formData['form_number'] ?? null,
'deceased_member_name' => $member['full_name_ar'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
......@@ -623,29 +714,39 @@ class DeathController extends Controller
}
$newMemberId = $db->insert('members', $newMemberData);
$auditLog[] = 'تم تحويل العضوية إلى: ' . ($formData['full_name_ar'] ?? $spouse['full_name_ar']) . ' — رقم العضوية: ' . $inheritedNumber;
// Archive primary spouse record
// Archive primary spouse
$db->update('spouses', [
'status' => 'transferred', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['primary_spouse_id']]);
// Handle children assignment from JSON map
// Handle children assignment
$childrenAssignment = !empty($case['children_assignment_json']) ? json_decode($case['children_assignment_json'], true) : [];
$childrenMoved = [];
if (!empty($childrenAssignment)) {
foreach ($childrenAssignment as $childId => $assignTo) {
if ($assignTo === 'primary') {
$db->update('children', ['member_id' => $newMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ? AND `is_archived` = 0', [(int) $childId]);
$childRow = $db->selectOne("SELECT full_name_ar FROM children WHERE id = ?", [(int) $childId]);
if ($childRow) $childrenMoved[] = $childRow['full_name_ar'];
}
}
} else {
// No explicit assignment map: all children go to primary
$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']]);
}
// Handle secondary spouses — create separate new memberships with NEW numbers
if (!empty($childrenMoved)) {
$auditLog[] = 'تم نقل الأبناء التاليين إلى العضوية الجديدة: ' . implode('، ', $childrenMoved);
} elseif (empty($childrenAssignment)) {
$auditLog[] = 'تم نقل جميع الأبناء إلى العضوية الجديدة';
}
// Handle secondary spouses
$secondaryMemberIds = [];
if (!empty($case['secondary_spouses_json'])) {
$secondaryIds = json_decode($case['secondary_spouses_json'], true);
foreach ($secondaryIds as $secSpouseId) {
......@@ -656,8 +757,8 @@ class DeathController extends Controller
'full_name_ar' => $secSpouse['full_name_ar'],
'full_name_en' => $secSpouse['full_name_en'] ?? null,
'national_id' => $secSpouse['national_id'],
'date_of_birth' => $secSpouse['date_of_birth'],
'gender' => $secSpouse['gender'],
'date_of_birth' => $secSpouse['date_of_birth'] ?? '1900-01-01',
'gender' => $secSpouse['gender'] ?? 'female',
'nationality' => $secSpouse['nationality'] ?? 'مصري',
'branch_id' => (int) $member['branch_id'],
'membership_type' => $member['membership_type'] ?? 'working',
......@@ -668,39 +769,46 @@ class DeathController extends Controller
'phone_mobile' => $secSpouse['mobile'] ?? null,
'membership_value' => $member['membership_value'],
'transferred_from_death_id' => (int) $id,
'deceased_member_name' => $member['full_name_ar'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
\App\Modules\Members\Services\MemberNumberGenerator::assign($secMemberId);
$secondaryMemberIds[(int) $secSpouseId] = $secMemberId;
$db->update('spouses', [
'status' => 'transferred', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $secSpouseId]);
// Move explicitly-assigned children to this secondary spouse member
$secNumber = $db->selectOne("SELECT membership_number FROM members WHERE id = ?", [$secMemberId]);
$auditLog[] = 'تم إنشاء عضوية منفصلة للزوجة: ' . $secSpouse['full_name_ar'] . ' — رقم: ' . ($secNumber['membership_number'] ?? '');
// Move children assigned to this secondary spouse
if (!empty($childrenAssignment)) {
foreach ($childrenAssignment as $childId => $assignTo) {
if ((int) $assignTo === (int) $secSpouseId) {
$db->update('children', ['member_id' => $secMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ? AND `is_archived` = 0', [(int) $childId]);
$childRow = $db->selectOne("SELECT full_name_ar FROM children WHERE id = ?", [(int) $childId]);
if ($childRow) {
$auditLog[] = 'تم نقل ' . $childRow['full_name_ar'] . ' إلى عضوية ' . $secSpouse['full_name_ar'];
}
}
}
}
}
}
// FIX: Sweep any remaining children still on deceased member to new primary
// (catches children not in assignment map, e.g. added after case was created)
// Sweep remaining children to primary
$db->query(
"UPDATE children SET member_id = ?, updated_at = ?
WHERE member_id = ? AND is_archived = 0",
"UPDATE children SET member_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0",
[$newMemberId, date('Y-m-d H:i:s'), (int) $case['member_id']]
);
// Renumber child_order for all children of new primary (1..n, sequential)
// Renumber child_order
$allChildren = $db->select(
"SELECT id FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order, id",
[$newMemberId]
......@@ -713,28 +821,74 @@ class DeathController extends Controller
], '`id` = ?', [(int) $c['id']]);
}
// Transfer temporary_members to new primary
$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']]);
// Transfer temporary members based on assignment
$tempsMoved = [];
if (!empty($tempsAssignment)) {
foreach ($tempsAssignment as $tempId => $assignTo) {
$targetMemberId = $newMemberId;
if ($assignTo !== 'primary' && isset($secondaryMemberIds[(int) $assignTo])) {
$targetMemberId = $secondaryMemberIds[(int) $assignTo];
}
$db->update('temporary_members', ['member_id' => $targetMemberId, 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ? AND `is_archived` = 0', [(int) $tempId]);
$tempRow = $db->selectOne("SELECT full_name_ar FROM temporary_members WHERE id = ?", [(int) $tempId]);
if ($tempRow) $tempsMoved[] = $tempRow['full_name_ar'];
}
}
// Sweep remaining temps to primary
$db->query(
"UPDATE temporary_members SET member_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0",
[$newMemberId, date('Y-m-d H:i:s'), (int) $case['member_id']]
);
if (!empty($tempsMoved)) {
$auditLog[] = 'تم نقل الأعضاء المؤقتين: ' . implode('، ', $tempsMoved);
} else {
$tempCount = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM temporary_members WHERE member_id = ? AND is_archived = 0", [$newMemberId])['cnt'] ?? 0);
if ($tempCount > 0) {
$auditLog[] = 'تم نقل ' . $tempCount . ' عضو مؤقت إلى العضوية الجديدة';
}
}
ArchiveService::recordNumberTransfer($inheritedNumber, 'death_transfer', 'members', $newMemberId);
$auditLog[] = 'تاريخ التنفيذ: ' . date('Y-m-d H:i:s');
$auditLog[] = 'بواسطة: ' . ($employee ? $employee->full_name_ar : 'النظام');
$auditLog[] = 'تم إنشاء العضوية الجديدة بعد اعتماد السداد من الخزينة';
// Store audit log in notes (merge with existing)
$finalNotes = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (!is_array($finalNotes)) $finalNotes = [];
$finalNotes['audit_log'] = $auditLog;
$db->update('death_cases', [
'transferred_to_member_id' => $newMemberId,
'archive_snapshot_id' => $snapshotId,
'status' => 'completed',
'notes' => json_encode($finalNotes, JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
$db->commit();
EventBus::dispatch('death.completed', ['case_id' => (int) $id, 'new_member_id' => $newMemberId]);
Logger::info("Death case #{$id} completed", ['new_member' => $newMemberId, 'audit' => $auditLog]);
return $this->redirect("/death/{$id}")->withSuccess('تم نقل العضوية بنفس الرقم — رقم العضوية: ' . $inheritedNumber);
}
// Non-primary-member death (spouse or child)
// Non-primary-member death
$auditLog[] = 'تاريخ التنفيذ: ' . date('Y-m-d H:i:s');
$auditLog[] = 'بواسطة: ' . ($employee ? $employee->full_name_ar : 'النظام');
$finalNotes = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
if (!is_array($finalNotes)) $finalNotes = [];
$finalNotes['audit_log'] = $auditLog;
$db->update('death_cases', [
'archive_snapshot_id' => $snapshotId,
'status' => 'completed',
'notes' => json_encode($finalNotes, JSON_UNESCAPED_UNICODE),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
......@@ -742,6 +896,7 @@ class DeathController extends Controller
return $this->redirect("/death/{$id}")->withSuccess('تم إتمام حالة الوفاة');
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Death case #{$id} failed: " . $e->getMessage());
return $this->redirect("/death/{$id}")->withError('فشل: ' . $e->getMessage());
}
}
......
......@@ -64,13 +64,13 @@
</div>
</div>
<!-- Primary member death: spouse selection + children assignment -->
<!-- Primary member death: spouse selection + children + temps assignment -->
<div id="primary-death-section" style="display:none;">
<?php if (count($spouses) > 0): ?>
<div class="card" style="margin-bottom:20px;border:2px solid #0D7377;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;background:#F0FDFA;">
<h3 style="margin:0;color:#0D7377;font-size:15px;">نقل العضوية — اختيار الزوجة الأساسية</h3>
<p style="margin:5px 0 0;font-size:12px;color:#6B7280;">الزوجة الأساسية ترث نفس رقم العضوية وتملأ استمارة جديدة (570 + اشتراك سنوي)</p>
<p style="margin:5px 0 0;font-size:12px;color:#6B7280;">الزوجة الأساسية ترث نفس رقم العضوية وتملأ استمارة جديدة</p>
</div>
<div style="padding:20px;">
<div class="form-group" style="margin-bottom:15px;">
......@@ -85,7 +85,7 @@
<?php if (count($spouses) > 1): ?>
<div class="form-group" style="margin-bottom:15px;">
<label class="form-label">الزوجات الإضافيات (عضويات منفصلة — 570 + اشتراك لكل واحدة)</label>
<label class="form-label">الزوجات الإضافيات (عضويات منفصلة)</label>
<?php foreach ($spouses as $s): ?>
<label style="display:flex;align-items:center;gap:8px;padding:5px 0;font-size:14px;" class="secondary-spouse-label" data-id="<?= (int) $s['id'] ?>">
<input type="checkbox" name="secondary_spouse_ids[]" value="<?= (int) $s['id'] ?>" class="secondary-spouse-cb">
......@@ -118,18 +118,52 @@
</div>
</div>
<?php endif; ?>
<?php if (count($temps) > 0): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#1F2937;font-size:15px;">توزيع الأعضاء المؤقتين</h3>
<p style="margin:5px 0 0;font-size:12px;color:#6B7280;">حدد لأي عضوية ينتقل كل عضو مؤقت (الافتراضي: الزوجة الأساسية)</p>
</div>
<div style="padding:20px;">
<?php foreach ($temps as $t): ?>
<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid #F3F4F6;">
<span style="font-weight:600;min-width:150px;"><?= e($t['full_name_ar']) ?></span>
<select name="temps_assignment[<?= (int) $t['id'] ?>]" class="form-select" style="max-width:250px;">
<option value="primary">الزوجة الأساسية</option>
<?php foreach ($spouses as $s): ?>
<option value="<?= (int) $s['id'] ?>"><?= e($s['full_name_ar']) ?> (منفصلة)</option>
<?php endforeach; ?>
</select>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<!-- Fee Summary -->
<div id="fee-section" style="display:none;">
<div class="card" style="padding:20px;margin-bottom:20px;background:#FFF7ED;border:2px solid #F59E0B;">
<h4 style="margin:0 0 15px;color:#D97706;">رسوم الإجراء</h4>
<h4 style="margin:0 0 15px;color:#D97706;">رسوم الإجراء (رسوم استمارة + اشتراك سنوي لجميع أفراد العضوية)</h4>
<table style="font-size:14px;margin-bottom:15px;" id="fee-table">
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">رسوم استمارة (570)</td><td style="font-weight:600;"><?= money($form_fee) ?></td></tr>
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">اشتراك سنوي</td><td style="font-weight:600;"><?= money($annual_sub) ?></td></tr>
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">رسوم استمارة</td><td style="font-weight:600;"><?= money($form_fee) ?></td></tr>
<tr><td colspan="2" style="padding:8px 0 4px;font-weight:600;color:#0D7377;">الاشتراك السنوي (<?= money($annual_sub) ?>):</td></tr>
<tr><td style="padding:2px 30px 2px 0;color:#6B7280;font-size:13px;">- العضو</td><td style="font-size:13px;"><?= money($family_sub['member_sub']) ?></td></tr>
<?php if ($family_sub['spouse_count'] > 0): ?>
<tr><td style="padding:2px 30px 2px 0;color:#6B7280;font-size:13px;">- <?= (int) $family_sub['spouse_count'] ?> زوجة</td><td style="font-size:13px;"><?= money(bcmul($family_sub['spouse_sub'], (string) $family_sub['spouse_count'], 2)) ?></td></tr>
<?php endif; ?>
<?php if ($family_sub['child_count'] > 0): ?>
<tr><td style="padding:2px 30px 2px 0;color:#6B7280;font-size:13px;">- <?= (int) $family_sub['child_count'] ?> أبناء</td><td style="font-size:13px;"><?= money(bcmul($family_sub['child_sub'], (string) $family_sub['child_count'], 2)) ?></td></tr>
<?php endif; ?>
<?php if ($family_sub['temp_count'] > 0): ?>
<tr><td style="padding:2px 30px 2px 0;color:#6B7280;font-size:13px;">- <?= (int) $family_sub['temp_count'] ?> مؤقتين</td><td style="font-size:13px;"><?= money(bcmul($family_sub['temp_sub'], (string) $family_sub['temp_count'], 2)) ?></td></tr>
<?php endif; ?>
<tr><td style="padding:2px 30px 2px 0;color:#6B7280;font-size:13px;">- رسوم تنمية</td><td style="font-size:13px;"><?= money($family_sub['dev_fee']) ?></td></tr>
<tr style="border-top:2px solid #D97706;" id="fee-total-row"><td style="padding:8px 20px 4px 0;font-weight:700;">الإجمالي</td><td style="font-weight:700;font-size:18px;color:#DC2626;"><?= money($total_fee) ?></td></tr>
</table>
<p style="font-size:11px;color:#6B7280;margin:0;">* سيتم إضافة رسوم مجلس الأمناء بعد اعتماد مجلس الإدارة</p>
</div>
</div>
......
......@@ -6,11 +6,30 @@
<strong style="color:#0D7377;">نقل عضوية بسبب وفاة</strong>
الزوجة <?= e($spouse['full_name_ar'] ?? '—') ?> ترث العضوية رقم <?= e($member['membership_number'] ?? '—') ?>
<br><small style="color:#6B7280;">يجب ملء جميع البيانات المطلوبة لاستمارة العضوية الجديدة</small>
<br><small style="color:#DC2626;font-weight:600;">العضو المتوفى: <?= e($member['full_name_ar']) ?></small>
</div>
<form method="POST" action="/death/<?= (int) $case['id'] ?>/fill-form">
<?= csrf_field() ?>
<!-- Form Number & Deceased Info -->
<div class="card" style="margin-bottom:20px;border:2px solid #F59E0B;">
<div style="padding:15px 20px;border-bottom:1px solid #FDE68A;background:#FFFBEB;">
<h3 style="margin:0;color:#D97706;font-size:15px;">بيانات الاستمارة</h3>
</div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">رقم الاستمارة <span style="color:#DC2626;">*</span></label>
<input type="text" name="form_number" class="form-input" placeholder="رقم الاستمارة الورقية" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">اسم العضو المتوفى</label>
<input type="text" value="<?= e($member['full_name_ar']) ?>" class="form-input" readonly style="background:#F3F4F6;font-weight:600;">
<input type="hidden" name="deceased_member_name" value="<?= e($member['full_name_ar']) ?>">
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">البيانات الشخصية</h3></div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
......
......@@ -180,12 +180,19 @@ $canApprove = can('transfer.approve');
$secondaryCount = count(json_decode($case['secondary_spouses_json'], true));
}
$multiplier = 1 + $secondaryCount;
$baseFeePerSpouse = bcadd($fees['formFee'], $fees['annualSub'], 2);
$baseFeeTotal = bcmul($baseFeePerSpouse, (string) $multiplier, 2);
?>
<table style="font-size:13px;max-width:400px;">
<table style="font-size:13px;max-width:500px;">
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">رسوم استمارة (<?= $multiplier ?> × <?= money($fees['formFee']) ?>)</td><td style="font-weight:600;"><?= money(bcmul($fees['formFee'], (string) $multiplier, 2)) ?></td></tr>
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">اشتراك سنوي (<?= $multiplier ?> × <?= money($fees['annualSub']) ?>)</td><td style="font-weight:600;"><?= money(bcmul($fees['annualSub'], (string) $multiplier, 2)) ?></td></tr>
<tr><td style="padding:4px 20px 4px 0;color:#6B7280;">اشتراك سنوي (جميع أفراد العضوية) × <?= $multiplier ?></td><td style="font-weight:600;"><?= money(bcmul($familySub['annual_sub_total'], (string) $multiplier, 2)) ?></td></tr>
<?php if ($familySub['spouse_count'] > 0 || $familySub['child_count'] > 0 || $familySub['temp_count'] > 0): ?>
<tr><td colspan="2" style="padding:2px 40px 2px 0;color:#9CA3AF;font-size:11px;">
(عضو: <?= money($familySub['member_sub']) ?>
<?php if ($familySub['spouse_count'] > 0): ?> + <?= $familySub['spouse_count'] ?> زوجة: <?= money(bcmul($familySub['spouse_sub'], (string) $familySub['spouse_count'], 2)) ?><?php endif; ?>
<?php if ($familySub['child_count'] > 0): ?> + <?= $familySub['child_count'] ?> أبناء: <?= money(bcmul($familySub['child_sub'], (string) $familySub['child_count'], 2)) ?><?php endif; ?>
<?php if ($familySub['temp_count'] > 0): ?> + <?= $familySub['temp_count'] ?> مؤقتين: <?= money(bcmul($familySub['temp_sub'], (string) $familySub['temp_count'], 2)) ?><?php endif; ?>
+ تنمية: <?= money($familySub['dev_fee']) ?>)
</td></tr>
<?php endif; ?>
<tr>
<td style="padding:4px 20px 4px 0;color:#6B7280;">
رسوم مجلس الأمناء
......@@ -262,6 +269,27 @@ $canApprove = can('transfer.approve');
</form>
<?php endif; ?>
<!-- Audit Trail (displayed when case is completed) -->
<?php
$notesData = !empty($case['notes']) ? json_decode($case['notes'], true) : [];
$auditLog = is_array($notesData) ? ($notesData['audit_log'] ?? []) : [];
?>
<?php if (!empty($auditLog)): ?>
<div class="card" style="margin-bottom:20px;margin-top:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;background:#F9FAFB;">
<h3 style="margin:0;color:#1F2937;font-size:15px;">سجل العمليات (Audit Trail)</h3>
</div>
<div style="padding:20px;">
<?php foreach ($auditLog as $entry): ?>
<div style="padding:6px 0;font-size:13px;border-bottom:1px solid #F3F4F6;display:flex;align-items:start;gap:8px;">
<span style="color:#0D7377;font-size:11px;margin-top:2px;"></span>
<span><?= e($entry) ?></span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('scripts'); ?>
......
......@@ -255,6 +255,23 @@ class MemberController extends Controller
);
}
// Check if this member was created via waiver transfer
$waiverTransfer = null;
$waiverArchiveSnapshot = null;
if (!empty($member->transferred_from_waiver_id)) {
$waiverTransfer = $db->selectOne(
"SELECT wr.id, wr.source_member_id, wr.membership_number, wr.waiver_fee_amount, wr.waiver_fee_percentage,
wr.archive_snapshot_id, wr.created_at as waiver_date,
m.full_name_ar as source_member_name, m.id as source_member_id_ref
FROM waiver_requests wr LEFT JOIN members m ON m.id = wr.source_member_id
WHERE wr.id = ?",
[(int) $member->transferred_from_waiver_id]
);
if ($waiverTransfer && $waiverTransfer['archive_snapshot_id']) {
$waiverArchiveSnapshot = \App\Modules\Archive\Services\ArchiveService::getSnapshot((int) $waiverTransfer['archive_snapshot_id']);
}
}
$instRateData = RuleEngine::get('INSTALLMENT_INTEREST_RATE');
$instMonthsData = RuleEngine::get('INSTALLMENT_MAX_MONTHS');
......@@ -276,6 +293,8 @@ class MemberController extends Controller
'cancelledRequests' => $cancelledRequests,
'isSuperAdmin' => self::isSuperAdmin(),
'divorceTransfer' => $divorceTransfer,
'waiverTransfer' => $waiverTransfer,
'waiverArchiveSnapshot' => $waiverArchiveSnapshot,
'installmentPlan' => $member->status === 'pending_cheques'
? $db->selectOne("SELECT id, number_of_months FROM installment_plans WHERE member_id = ? AND status = 'active' ORDER BY id DESC LIMIT 1", [(int) $id])
: null,
......
......@@ -112,7 +112,10 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<span style="background:#FEF3C7;padding:2px 8px;border-radius:10px;font-size:11px;">محوّل من عضوية: <a href="/members/<?= (int) $divorceTransfer['original_member_id'] ?>" style="color:#D97706;font-weight:700;"><?= e($divorceTransfer['original_membership_number']) ?></a> (<?= e($divorceTransfer['original_member_name'] ?? '') ?>)</span>
<?php endif; ?>
<?php if (!empty($member->transferred_from_death_id)): ?>
<span style="background:#DBEAFE;padding:2px 8px;border-radius:10px;font-size:11px;">أُنشئت من <a href="/death/<?= (int) $member->transferred_from_death_id ?>" style="color:#2563EB;font-weight:700;">حالة وفاة #<?= (int) $member->transferred_from_death_id ?></a></span>
<span style="background:#DBEAFE;padding:2px 8px;border-radius:10px;font-size:11px;">أُنشئت من <a href="/death/<?= (int) $member->transferred_from_death_id ?>" style="color:#2563EB;font-weight:700;">حالة وفاة #<?= (int) $member->transferred_from_death_id ?></a><?php if (!empty($member->deceased_member_name)): ?> (<?= e($member->deceased_member_name) ?>)<?php endif; ?></span>
<?php endif; ?>
<?php if (!empty($waiverTransfer)): ?>
<span style="background:#F3E8FF;padding:2px 8px;border-radius:10px;font-size:11px;">تنازل من: <a href="/waivers/<?= (int) $waiverTransfer['id'] ?>" style="color:#7C3AED;font-weight:700;"><?= e($waiverTransfer['source_member_name'] ?? $member->waived_from_member_name ?? '—') ?></a></span>
<?php endif; ?>
<span>📋 استمارة: <strong style="color:#D97706;"><?= e($member->form_number ?? '—') ?></strong></span>
<span>🏢 <?= e($branchName) ?></span>
......@@ -1117,6 +1120,214 @@ $childClassLabels = ['included' => 'تابع مشمول', 'dependent_with_fee' =
</div>
<?php endif; ?>
<!-- ═══════════════════════════════════════════════ -->
<!-- WAIVER TRANSFER SOURCE & ARCHIVE -->
<!-- ═══════════════════════════════════════════════ -->
<?php if (!empty($waiverTransfer)): ?>
<div class="card" style="margin-bottom:20px;border:2px solid #7C3AED;">
<div style="padding:15px 20px;border-bottom:1px solid #DDD6FE;background:#F5F3FF;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">📤 هذه العضوية ناتجة عن تنازل</h3>
<a href="/waivers/<?= (int) $waiverTransfer['id'] ?>" class="btn btn-sm btn-outline" style="color:#7C3AED;border-color:#7C3AED;">عرض طلب التنازل</a>
</div>
<div style="padding:20px;">
<table style="width:100%;max-width:600px;font-size:14px;">
<tr>
<td style="padding:6px 0;color:#6B7280;width:35%;">العضو المتنازل</td>
<td style="padding:6px 0;font-weight:700;"><?= e($waiverTransfer['source_member_name'] ?? $member->waived_from_member_name ?? '—') ?></td>
</tr>
<tr>
<td style="padding:6px 0;color:#6B7280;">رقم العضوية المنقول</td>
<td style="padding:6px 0;font-weight:600;color:#0D7377;"><?= e($waiverTransfer['membership_number'] ?? $member->membership_number ?? '—') ?></td>
</tr>
<?php if (!empty($waiverTransfer['waiver_fee_amount']) && bccomp($waiverTransfer['waiver_fee_amount'], '0', 2) > 0): ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">رسوم التنازل</td>
<td style="padding:6px 0;font-weight:600;"><?= money($waiverTransfer['waiver_fee_amount']) ?> (<?= e($waiverTransfer['waiver_fee_percentage'] ?? '30') ?>%)</td>
</tr>
<?php endif; ?>
<tr>
<td style="padding:6px 0;color:#6B7280;">تاريخ التنازل</td>
<td style="padding:6px 0;"><?= e(substr($waiverTransfer['waiver_date'] ?? '', 0, 10)) ?></td>
</tr>
</table>
<?php if (!empty($waiverArchiveSnapshot)): ?>
<hr style="border:none;border-top:1px solid #E5E7EB;margin:20px 0;">
<div style="margin-bottom:10px;">
<button type="button" onclick="document.getElementById('waiver-archive-detail').style.display = document.getElementById('waiver-archive-detail').style.display === 'none' ? 'block' : 'none';"
class="btn btn-sm btn-outline" style="color:#7C3AED;border-color:#7C3AED;">
🗄️ عرض أرشيف العضو السابق
</button>
<span style="font-size:12px;color:#9CA3AF;margin-right:10px;">لقطة كاملة من تاريخ <?= e(substr($waiverArchiveSnapshot['snapshot_taken_at'] ?? '', 0, 10)) ?></span>
</div>
<div id="waiver-archive-detail" style="display:none;background:#FAFAFA;border:1px solid #E5E7EB;border-radius:8px;padding:20px;margin-top:10px;">
<?php
$archiveData = $waiverArchiveSnapshot['full_data'] ?? [];
$archiveRelated = $waiverArchiveSnapshot['related_data'] ?? [];
?>
<!-- Personal Data -->
<h4 style="margin:0 0 10px;color:#374151;font-size:14px;border-bottom:1px solid #E5E7EB;padding-bottom:8px;">البيانات الشخصية للعضو السابق</h4>
<table style="width:100%;font-size:13px;margin-bottom:20px;">
<?php
$archiveFields = [
'full_name_ar' => 'الاسم بالعربي',
'full_name_en' => 'الاسم بالإنجليزي',
'national_id' => 'الرقم القومي',
'date_of_birth' => 'تاريخ الميلاد',
'gender' => 'النوع',
'nationality' => 'الجنسية',
'phone_mobile' => 'الهاتف المحمول',
'email' => 'البريد الإلكتروني',
'residence_address' => 'العنوان',
'membership_number' => 'رقم العضوية',
'membership_type' => 'نوع العضوية',
'membership_value' => 'قيمة العضوية',
'status' => 'الحالة',
'activated_at' => 'تاريخ التفعيل',
];
foreach ($archiveFields as $key => $label):
if (!empty($archiveData[$key])):
?>
<tr>
<td style="padding:4px 0;color:#6B7280;width:30%;"><?= e($label) ?></td>
<td style="padding:4px 0;"><?= e((string) $archiveData[$key]) ?></td>
</tr>
<?php endif; endforeach; ?>
</table>
<!-- Dependents -->
<?php if (!empty($archiveRelated['spouses'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">الزوجات (<?= count($archiveRelated['spouses']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">الاسم</th><th style="padding:6px;">الرقم القومي</th><th style="padding:6px;">الحالة</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['spouses'] as $as): ?>
<tr style="border-bottom:1px solid #F3F4F6;"><td style="padding:5px;"><?= e($as['full_name_ar'] ?? '') ?></td><td style="padding:5px;direction:ltr;"><?= e($as['national_id'] ?? '') ?></td><td style="padding:5px;"><?= e($as['status'] ?? '') ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (!empty($archiveRelated['children'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">الأبناء (<?= count($archiveRelated['children']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">الاسم</th><th style="padding:6px;">تاريخ الميلاد</th><th style="padding:6px;">الرقم القومي</th><th style="padding:6px;">الحالة</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['children'] as $ac): ?>
<tr style="border-bottom:1px solid #F3F4F6;"><td style="padding:5px;"><?= e($ac['full_name_ar'] ?? '') ?></td><td style="padding:5px;"><?= e($ac['date_of_birth'] ?? '') ?></td><td style="padding:5px;direction:ltr;"><?= e($ac['national_id'] ?? '') ?></td><td style="padding:5px;"><?= e($ac['status'] ?? '') ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php if (!empty($archiveRelated['temporary_members'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">الأعضاء المؤقتون (<?= count($archiveRelated['temporary_members']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">الاسم</th><th style="padding:6px;">الرقم القومي</th><th style="padding:6px;">الحالة</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['temporary_members'] as $at): ?>
<tr style="border-bottom:1px solid #F3F4F6;"><td style="padding:5px;"><?= e($at['full_name_ar'] ?? '') ?></td><td style="padding:5px;direction:ltr;"><?= e($at['national_id'] ?? '') ?></td><td style="padding:5px;"><?= e($at['status'] ?? '') ?></td></tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Payments -->
<?php if (!empty($archiveRelated['payments'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">المدفوعات (<?= count($archiveRelated['payments']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">النوع</th><th style="padding:6px;">المبلغ</th><th style="padding:6px;">التاريخ</th><th style="padding:6px;">رقم الإيصال</th></tr></thead>
<tbody>
<?php foreach (array_slice($archiveRelated['payments'], 0, 20) as $ap): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px;"><?= e($ap['payment_type'] ?? '') ?></td>
<td style="padding:5px;font-weight:600;"><?= money($ap['amount'] ?? '0') ?></td>
<td style="padding:5px;"><?= e(substr($ap['payment_date'] ?? $ap['created_at'] ?? '', 0, 10)) ?></td>
<td style="padding:5px;direction:ltr;"><?= e($ap['receipt_number'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
<?php if (count($archiveRelated['payments']) > 20): ?>
<tr><td colspan="4" style="padding:5px;color:#6B7280;text-align:center;">... و<?= count($archiveRelated['payments']) - 20 ?> مدفوعات أخرى</td></tr>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Subscriptions -->
<?php if (!empty($archiveRelated['subscriptions'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">الاشتراكات السنوية (<?= count($archiveRelated['subscriptions']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">السنة المالية</th><th style="padding:6px;">الشخص</th><th style="padding:6px;">المبلغ</th><th style="padding:6px;">الحالة</th></tr></thead>
<tbody>
<?php foreach (array_slice($archiveRelated['subscriptions'], 0, 30) as $asub): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px;"><?= e($asub['financial_year'] ?? '') ?></td>
<td style="padding:5px;"><?= e($asub['person_name'] ?? 'العضو') ?></td>
<td style="padding:5px;"><?= money($asub['total_amount'] ?? '0') ?></td>
<td style="padding:5px;"><?= e($asub['status'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Fines -->
<?php if (!empty($archiveRelated['fines'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">الغرامات (<?= count($archiveRelated['fines']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">المبلغ</th><th style="padding:6px;">الحالة</th><th style="padding:6px;">ملاحظات</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['fines'] as $af): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px;"><?= money($af['amount'] ?? '0') ?></td>
<td style="padding:5px;"><?= e($af['status'] ?? '') ?></td>
<td style="padding:5px;max-width:200px;overflow:hidden;text-overflow:ellipsis;"><?= e($af['notes'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Installments -->
<?php if (!empty($archiveRelated['installment_plans'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">خطط الأقساط (<?= count($archiveRelated['installment_plans']) ?>)</h4>
<table style="width:100%;font-size:12px;margin-bottom:15px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">المبلغ</th><th style="padding:6px;">عدد الأشهر</th><th style="padding:6px;">الحالة</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['installment_plans'] as $aip): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px;"><?= money($aip['total_amount'] ?? '0') ?></td>
<td style="padding:5px;"><?= e($aip['number_of_months'] ?? '') ?></td>
<td style="padding:5px;"><?= e($aip['status'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<!-- Documents -->
<?php if (!empty($archiveRelated['documents'])): ?>
<h4 style="margin:15px 0 8px;color:#374151;font-size:13px;border-bottom:1px solid #E5E7EB;padding-bottom:6px;">المستندات (<?= count($archiveRelated['documents']) ?>)</h4>
<table style="width:100%;font-size:12px;">
<thead><tr style="background:#F9FAFB;"><th style="padding:6px;text-align:right;">النوع</th><th style="padding:6px;">اسم الملف</th><th style="padding:6px;">تاريخ الرفع</th></tr></thead>
<tbody>
<?php foreach ($archiveRelated['documents'] as $adoc): ?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:5px;"><?= e($adoc['document_type'] ?? '') ?></td>
<td style="padding:5px;"><?= e($adoc['original_filename'] ?? '') ?></td>
<td style="padding:5px;"><?= e(substr($adoc['uploaded_at'] ?? '', 0, 10)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
function toggleBillDetail(idx) {
var el = document.getElementById('bill-detail-' + idx);
......
......@@ -52,13 +52,15 @@ final class WaiverProcessor
], '`id` = ?', [(int) $waiver['source_member_id']]);
$db->update('members', [
'membership_number' => $waiver['membership_number'],
'status' => 'active',
'membership_type' => $sourceMember['membership_type'] ?? 'working',
'activated_by_payment_id' => $waiverPaymentId,
'activated_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'),
'membership_number' => $waiver['membership_number'],
'status' => 'active',
'membership_type' => $sourceMember['membership_type'] ?? 'working',
'membership_value' => $sourceMember['membership_value'] ?? '0.00',
'activated_by_payment_id' => $waiverPaymentId,
'activated_at' => date('Y-m-d H:i:s'),
'transferred_from_waiver_id' => $waiverId,
'waived_from_member_name' => $sourceMember['full_name_ar'] ?? null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $waiver['target_member_id']]);
ArchiveService::recordNumberTransfer($waiver['membership_number'], 'waiver', 'members', (int) $waiver['target_member_id']);
......@@ -71,12 +73,13 @@ final class WaiverProcessor
$db->commit();
// Generate subscription rows for target member and mark as paid
// Generate subscription rows for target member and mark ALL family as paid
$targetMemberId = (int) $waiver['target_member_id'];
try {
SubscriptionSyncService::syncForMember($targetMemberId);
// Mark current year subscriptions as paid (fee was collected in waiver payment)
// Mark current year subscriptions as paid for ALL family members
// (member + spouses + children + temporary — the waiver fee includes annual subscription)
$now = new \DateTime();
$month = (int) $now->format('n');
$year = (int) $now->format('Y');
......@@ -84,13 +87,12 @@ final class WaiverProcessor
$currentFy = $fyStart . '/' . ($fyStart + 1);
$pendingSubs = $db->select(
"SELECT id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
"SELECT id, total_amount, fine_amount, development_fee FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
[$targetMemberId, $currentFy]
);
$ts = date('Y-m-d H:i:s');
$paidByEmp = $employee ? (int) $employee->id : null;
foreach ($pendingSubs as $ps) {
$sub = $db->selectOne("SELECT total_amount, fine_amount, development_fee FROM subscriptions WHERE id = ?", [(int) $ps['id']]);
foreach ($pendingSubs as $sub) {
$paidAmount = bcadd(bcadd($sub['total_amount'], $sub['fine_amount'] ?? '0', 2), $sub['development_fee'] ?? '0', 2);
$db->update('subscriptions', [
'paid_amount' => $paidAmount,
......@@ -99,8 +101,22 @@ final class WaiverProcessor
'paid_at' => $ts,
'paid_by' => $paidByEmp,
'updated_at' => $ts,
], '`id` = ?', [(int) $ps['id']]);
], '`id` = ?', [(int) $sub['id']]);
}
// Also update dependent records to mark them as activated
$db->query(
"UPDATE spouses SET activated_by_payment_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0 AND activated_by_payment_id IS NULL",
[$waiverPaymentId, $ts, $targetMemberId]
);
$db->query(
"UPDATE children SET activated_by_payment_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0 AND activated_by_payment_id IS NULL",
[$waiverPaymentId, $ts, $targetMemberId]
);
$db->query(
"UPDATE temporary_members SET activated_by_payment_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0 AND activated_by_payment_id IS NULL",
[$waiverPaymentId, $ts, $targetMemberId]
);
} catch (\Throwable $e) {
Logger::warning("Waiver subscription sync failed: " . $e->getMessage());
}
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE members ADD COLUMN form_number VARCHAR(50) NULL AFTER original_membership_number;
ALTER TABLE members ADD COLUMN deceased_member_name VARCHAR(255) NULL AFTER form_number
",
'down' => "
ALTER TABLE members DROP COLUMN IF EXISTS form_number;
ALTER TABLE members DROP COLUMN IF EXISTS deceased_member_name
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE members ADD COLUMN transferred_from_waiver_id BIGINT UNSIGNED NULL AFTER transferred_from_death_id;
ALTER TABLE members ADD COLUMN waived_from_member_name VARCHAR(255) NULL AFTER transferred_from_waiver_id
",
'down' => "
ALTER TABLE members DROP COLUMN IF EXISTS waived_from_member_name;
ALTER TABLE members DROP COLUMN IF EXISTS transferred_from_waiver_id
",
];
......@@ -597,6 +597,8 @@ These modules have the highest change risk due to many downstream consumers:
| activated_at | Reports | Cashier/bootstrap (on activation) |
| special_discount_id | BillingService, Members/show | Members/applyDiscount, Members/saveFillForm |
| transferred_from_divorce_id | Members/show (origin display) | Divorce/DivorceController |
| transferred_from_waiver_id | Members/show (origin display + archive) | Waiver/WaiverProcessor |
| waived_from_member_name | Members/show (source name display) | Waiver/WaiverProcessor |
**Events dispatched and full listener chains:**
......
# Death Module — Architecture Map
> **Last updated:** 2026-06-10
> **Last updated:** 2026-07-21
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -12,10 +12,14 @@ The Death module manages **membership transfer upon death** of a member or their
- Transferring membership to the primary spouse (inherits SAME membership number)
- Creating separate new memberships for secondary spouses (get NEW numbers)
- Assigning children to the new primary member or secondary spouses
- Fee collection (form fee + annual subscription per spouse receiving membership)
- Assigning temporary members to the new primary member or secondary spouses
- Fee collection (form fee + annual subscription for ALL family members)
- Board review & trustee fee calculation before payment
- Form fill step (wife fills full membership application form before transfer completes)
- Death certificate documentation
- Form number tracking (رقم الاستمارة) and deceased member name on new membership
- Death certificate & inheritance notice documentation
- Archive snapshot of deceased member
- Full Arabic audit trail logging of all operations during completion
It does **NOT** directly manage:
- Separation/transfer logic for non-death scenarios (Transfers module)
......@@ -83,13 +87,14 @@ app/Modules/Death/
## 4. Death Case Status Lifecycle
```
recorded → fee_paid → pending_form_fill → completed
(primary_member only)
recorded → board_review → board_approved → fee_paid → pending_form_fill → completed
(primary_member only)
→ rejected (if board rejects)
recorded → completed (for spouse/child death — no fee, simpler flow)
```
**Status values:** recorded, fee_paid, pending_form_fill, completed
**Status values:** recorded, board_review, board_approved, rejected, fee_paid, pending_form_fill, completed
**Key invariant:** For `primary_member` death, status must pass through `pending_form_fill` to ensure the receiving spouse fills out a full membership application before transfer completes.
......@@ -170,12 +175,19 @@ recorded → completed (for spouse/child death — no fee, simpler flow)
```
For primary_member death:
Form Fee = SVC_TRANSFER_FORM (default 570 EGP)
Annual Sub = SVC_ANNUAL_MEMBER + DEVELOPMENT_FEE (default 492 + 35 = 527 EGP)
Annual Sub = SVC_ANNUAL_MEMBER + (spouses × SVC_ANNUAL_SPOUSE) + (children × SVC_ANNUAL_CHILD)
+ (temps × SVC_ANNUAL_TEMP) + DEVELOPMENT_FEE (once)
Example: member + 1 spouse + 2 children + 1 temp = 492 + 492 + 222×2 + 222 + 35 = 1,685 EGP
Base per membership = Form Fee + Annual Sub (all family)
Total Base = Base × (1 + number_of_secondary_spouses)
Trustee Fee = percentage of membership_value OR fixed amount (set during board approval)
Total = (Form Fee + Annual Sub) × (1 + number_of_secondary_spouses)
Grand Total = Total Base + Trustee Fee
Primary spouse: pays form_fee + annual_sub
Each secondary spouse: pays form_fee + annual_sub additionally
Payment receipt is created in the PRIMARY SPOUSE's name (not the deceased).
For spouse/child death: fee_amount = 0 (no transfer)
```
......@@ -213,7 +225,7 @@ For spouse/child death: fee_amount = 0 (no transfer)
| Payments | PaymentService (processPayment for direct pay) |
| Cashier | PaymentRequestService (queue payment for cashier processing) |
| Rules | RuleEngine (DEVELOPMENT_FEE) |
| ServiceCatalog | ServicePrice (SVC_TRANSFER_FORM, SVC_ANNUAL_MEMBER) |
| ServiceCatalog | ServicePrice (SVC_TRANSFER_FORM, SVC_ANNUAL_MEMBER, SVC_ANNUAL_SPOUSE, SVC_ANNUAL_CHILD, SVC_ANNUAL_TEMP) |
### 8.2 Other modules that IMPORT FROM Death:
......@@ -242,7 +254,10 @@ The Death module has **no own permissions**. It reuses Transfers module permissi
| Service Catalog Codes | Purpose |
|----------------------|---------|
| SVC_TRANSFER_FORM | Form fee (default 570 EGP) |
| SVC_ANNUAL_MEMBER | Annual subscription base (default 492 EGP) |
| SVC_ANNUAL_MEMBER | Annual subscription base — member (default 492 EGP) |
| SVC_ANNUAL_SPOUSE | Annual subscription — spouse (default 492 EGP) |
| SVC_ANNUAL_CHILD | Annual subscription — child (default 222 EGP) |
| SVC_ANNUAL_TEMP | Annual subscription — temp member (default 222 EGP) |
| Rule Engine Keys | Purpose |
|-----------------|---------|
......@@ -292,7 +307,7 @@ The Death module has **no own permissions**. It reuses Transfers module permissi
3. **No soft delete on model**: DeathCase records are permanent (no is_archived column)
4. **Form fill is mandatory for primary death**: Cannot complete without `primary_spouse_form_filled = 1`
5. **Children default to primary**: If `children_assignment_json` is empty, ALL children go to new primary member
6. **Temporary members always go to primary**: No assignment choice for temporary_members
6. **Temporary members can be assigned**: Distribution UI (like children) allows assigning temps to primary or secondary spouses
7. **activated_by_payment_id**: New member records link to the death_fee payment ID for audit trail
8. **Spouse/child death is simpler**: Just archives the dependent record, no membership transfer
9. **Dual payment paths**: Payment via Cashier queue (normal) or direct via `/pay` endpoint
......
# Waiver Module — Architecture Map
> **Last updated:** 2026-07-02 (Round 3: fully comprehensive debt check covering sales/seasonal/all payment types, board sees ALL dependents not just excess, per-individual detailed receipts with audit trail)
> **Last updated:** 2026-07-21 (Round 4: post-completion subscription marking for ALL family, waiver source tracking on members, membership_value transfer, archive display on member profile)
> **Status:** Living document — incrementally updated as new information is discovered
---
......
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