Commit cfa6456e authored by Fares's avatar Fares

fix: comprehensive subscription, transfer, and archive fixes (10 issues)

1. Subscription sync: add refreshForMember() and markCurrentYearPaid()
   to handle post-transfer subscription creation and payment marking.
2. Event listeners: transfer.completed and death.completed now trigger
   subscription sync for new members and cleanup for source members.
3. Waiver: stop transferring ALL payments/subscriptions/fines to target.
   Only pending subscriptions, active installments, and unpaid fines move.
   Paid history stays with the archived source member.
4. Death: createNewSubscriptions() now marks all rows as 'paid' since
   the annual fee is collected as part of the death transfer fee.
5. Death dependent: remove pending subscription rows when a spouse/child
   dies (not the primary member).
6. Members: add ownership_transfer_date column (migration + display)
   to record when membership ownership was transferred.
7. Archive: group subscriptions by financial year with summary totals.
8. Arabize: archive views (entity_type, snapshot_reason, NULL→فارغ),
   subscription statuses, and payment type labels.
9. Seasonal: fix duplicate national_id error by checking all members
   (including archived) during validation.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 8b87f1ea
...@@ -139,8 +139,8 @@ ...@@ -139,8 +139,8 @@
<?php if (isset($fc['old']) || isset($fc['new'])): ?> <?php if (isset($fc['old']) || isset($fc['new'])): ?>
<tr> <tr>
<td style="padding:4px 10px;font-weight:600;"><?= e($fk) ?></td> <td style="padding:4px 10px;font-weight:600;"><?= e($fk) ?></td>
<td style="padding:4px 10px;color:#DC2626;background:#FEF2F2;"><?= e((string) ($fc['old'] ?? 'NULL')) ?></td> <td style="padding:4px 10px;color:#DC2626;background:#FEF2F2;"><?= e((string) ($fc['old'] ?? 'فارغ')) ?></td>
<td style="padding:4px 10px;color:#059669;background:#F0FDF4;"><?= e((string) ($fc['new'] ?? 'NULL')) ?></td> <td style="padding:4px 10px;color:#059669;background:#F0FDF4;"><?= e((string) ($fc['new'] ?? 'فارغ')) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
......
<?php $__template->layout('Layout.main'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الأرشيف<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>الأرشيف<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php
$entityTypeLabels = ['members' => 'أعضاء', 'employees' => 'موظفين', 'roles' => 'أدوار', 'spouses' => 'زوجات', 'children' => 'أبناء', 'temporary_members' => 'مؤقتين'];
$reasonLabels = ['transfer' => 'تحويل', 'separation' => 'فصل', 'divorce' => 'طلاق', 'death' => 'وفاة', 'waiver' => 'تنازل', 'status_change' => 'تغيير حالة', 'data_correction' => 'تصحيح بيانات', 'manual' => 'يدوي'];
?>
<div class="card" style="margin-bottom:20px;padding:15px;"> <div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/archive" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;"> <form method="GET" action="/archive" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div> <div>
...@@ -12,7 +16,7 @@ ...@@ -12,7 +16,7 @@
<select name="entity_type" class="form-select" style="min-width:120px;"> <select name="entity_type" class="form-select" style="min-width:120px;">
<option value="">الكل</option> <option value="">الكل</option>
<?php foreach ($entityTypes as $et): ?> <?php foreach ($entityTypes as $et): ?>
<option value="<?= e($et) ?>" <?= ($filters['entity_type'] ?? '') === $et ? 'selected' : '' ?>><?= e($et) ?></option> <option value="<?= e($et) ?>" <?= ($filters['entity_type'] ?? '') === $et ? 'selected' : '' ?>><?= e($entityTypeLabels[$et] ?? $et) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
...@@ -21,7 +25,7 @@ ...@@ -21,7 +25,7 @@
<select name="snapshot_reason" class="form-select" style="min-width:120px;"> <select name="snapshot_reason" class="form-select" style="min-width:120px;">
<option value="">الكل</option> <option value="">الكل</option>
<?php foreach ($reasons as $r): ?> <?php foreach ($reasons as $r): ?>
<option value="<?= e($r) ?>" <?= ($filters['snapshot_reason'] ?? '') === $r ? 'selected' : '' ?>><?= e($r) ?></option> <option value="<?= e($r) ?>" <?= ($filters['snapshot_reason'] ?? '') === $r ? 'selected' : '' ?>><?= e($reasonLabels[$r] ?? $r) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
...@@ -63,7 +67,7 @@ ...@@ -63,7 +67,7 @@
<tr> <tr>
<td><?= (int) $r['id'] ?></td> <td><?= (int) $r['id'] ?></td>
<td style="font-size:12px;white-space:nowrap;"><?= e($r['snapshot_taken_at']) ?></td> <td style="font-size:12px;white-space:nowrap;"><?= e($r['snapshot_taken_at']) ?></td>
<td><code style="font-size:12px;"><?= e($r['entity_type']) ?></code></td> <td><code style="font-size:12px;"><?= e($entityTypeLabels[$r['entity_type']] ?? $r['entity_type']) ?></code></td>
<td><?= (int) $r['entity_id'] ?></td> <td><?= (int) $r['entity_id'] ?></td>
<td> <td>
<?php if ($r['membership_number']): ?> <?php if ($r['membership_number']): ?>
...@@ -81,7 +85,7 @@ ...@@ -81,7 +85,7 @@
]; ];
$color = $reasonColors[$r['snapshot_reason']] ?? '#6B7280'; $color = $reasonColors[$r['snapshot_reason']] ?? '#6B7280';
?> ?>
<span style="color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($r['snapshot_reason']) ?></span> <span style="color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($reasonLabels[$r['snapshot_reason']] ?? $r['snapshot_reason']) ?></span>
</td> </td>
<td style="font-size:13px;"><?= $r['snapshot_taken_by'] ? '#' . (int) $r['snapshot_taken_by'] : 'النظام' ?></td> <td style="font-size:13px;"><?= $r['snapshot_taken_by'] ? '#' . (int) $r['snapshot_taken_by'] : 'النظام' ?></td>
<td style="font-size:12px;color:#6B7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['notes'] ?? '', 0, 80)) ?: '—' ?></td> <td style="font-size:12px;color:#6B7280;max-width:200px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['notes'] ?? '', 0, 80)) ?: '—' ?></td>
......
...@@ -12,6 +12,9 @@ ...@@ -12,6 +12,9 @@
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<?php
$reasonLabels = ['transfer' => 'تحويل', 'separation' => 'فصل', 'divorce' => 'طلاق', 'death' => 'وفاة', 'waiver' => 'تنازل', 'status_change' => 'تغيير حالة', 'data_correction' => 'تصحيح بيانات', 'manual' => 'يدوي'];
?>
<?php if (!empty($isChainView)): ?> <?php if (!empty($isChainView)): ?>
<!-- Number Chain View --> <!-- Number Chain View -->
...@@ -30,13 +33,17 @@ ...@@ -30,13 +33,17 @@
</div> </div>
<div style="flex:1;"> <div style="flex:1;">
<div style="display:flex;justify-content:space-between;margin-bottom:5px;"> <div style="display:flex;justify-content:space-between;margin-bottom:5px;">
<strong style="color:#1A1A2E;"><?= e($link['holder_type']) ?></strong> <?php
$holderTypeLabels = ['death_transfer' => 'نقل وفاة', 'waiver' => 'تنازل', 'transfer' => 'تحويل', 'separation' => 'فصل', 'divorce' => 'طلاق', 'original' => 'أصلي', 'new_membership' => 'عضوية جديدة'];
?>
<strong style="color:#1A1A2E;"><?= e($holderTypeLabels[$link['holder_type']] ?? $link['holder_type']) ?></strong>
<?php if ($link['held_until'] === null): ?> <?php if ($link['held_until'] === null): ?>
<span style="background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;">الحالي</span> <span style="background:#F0FDF4;color:#059669;padding:2px 8px;border-radius:4px;font-size:12px;">الحالي</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="font-size:13px;color:#6B7280;"> <div style="font-size:13px;color:#6B7280;">
الكيان: <code><?= e($link['holder_entity_type']) ?></code> #<?= (int) $link['holder_entity_id'] ?> <?php $chainEntityLabels = ['members' => 'أعضاء', 'employees' => 'موظفين']; ?>
الكيان: <code><?= e($chainEntityLabels[$link['holder_entity_type']] ?? $link['holder_entity_type']) ?></code> #<?= (int) $link['holder_entity_id'] ?>
</div> </div>
<div style="font-size:12px;color:#9CA3AF;margin-top:4px;"> <div style="font-size:12px;color:#9CA3AF;margin-top:4px;">
من: <?= e($link['held_from']) ?> من: <?= e($link['held_from']) ?>
...@@ -71,7 +78,7 @@ ...@@ -71,7 +78,7 @@
<tr> <tr>
<td><?= (int) $s['id'] ?></td> <td><?= (int) $s['id'] ?></td>
<td style="font-size:12px;"><?= e($s['snapshot_taken_at']) ?></td> <td style="font-size:12px;"><?= e($s['snapshot_taken_at']) ?></td>
<td style="font-weight:600;"><?= e($s['snapshot_reason']) ?></td> <td style="font-weight:600;"><?= e($reasonLabels[$s['snapshot_reason']] ?? $s['snapshot_reason']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e(mb_substr($s['notes'] ?? '', 0, 100)) ?: '—' ?></td> <td style="font-size:12px;color:#6B7280;"><?= e(mb_substr($s['notes'] ?? '', 0, 100)) ?: '—' ?></td>
<td><a href="/archive/<?= (int) $s['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td> <td><a href="/archive/<?= (int) $s['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr> </tr>
...@@ -90,7 +97,7 @@ ...@@ -90,7 +97,7 @@
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;color:#0D7377;">معلومات اللقطة</h3> <h3 style="margin:0;color:#0D7377;">معلومات اللقطة</h3>
<span style="background:#F3F4F6;padding:4px 12px;border-radius:4px;font-size:13px;font-weight:600;"><?= e($snapshot['snapshot_reason']) ?></span> <span style="background:#F3F4F6;padding:4px 12px;border-radius:4px;font-size:13px;font-weight:600;"><?= e($reasonLabels[$snapshot['snapshot_reason']] ?? $snapshot['snapshot_reason']) ?></span>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<table style="width:100%;font-size:14px;"> <table style="width:100%;font-size:14px;">
...@@ -122,7 +129,7 @@ ...@@ -122,7 +129,7 @@
<?php if (is_array($value)): ?> <?php if (is_array($value)): ?>
<code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:3px;"><?= e(json_encode($value, JSON_UNESCAPED_UNICODE)) ?></code> <code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:3px;"><?= e(json_encode($value, JSON_UNESCAPED_UNICODE)) ?></code>
<?php elseif ($value === null): ?> <?php elseif ($value === null): ?>
<span style="color:#D1D5DB;">NULL</span> <span style="color:#D1D5DB;">فارغ</span>
<?php elseif (mb_strlen((string) $value) > 100): ?> <?php elseif (mb_strlen((string) $value) > 100): ?>
<details> <details>
<summary style="cursor:pointer;color:#0D7377;font-size:12px;">عرض (<?= mb_strlen((string) $value) ?> حرف)</summary> <summary style="cursor:pointer;color:#0D7377;font-size:12px;">عرض (<?= mb_strlen((string) $value) ?> حرف)</summary>
...@@ -164,7 +171,7 @@ ...@@ -164,7 +171,7 @@
<td style="padding:3px 0;color:#6B7280;width:35%;"><?= e($rk) ?></td> <td style="padding:3px 0;color:#6B7280;width:35%;"><?= e($rk) ?></td>
<td style="padding:3px 0;"> <td style="padding:3px 0;">
<?php if ($rv === null): ?> <?php if ($rv === null): ?>
<span style="color:#D1D5DB;">NULL</span> <span style="color:#D1D5DB;">فارغ</span>
<?php elseif (is_array($rv)): ?> <?php elseif (is_array($rv)): ?>
<code style="font-size:10px;"><?= e(json_encode($rv, JSON_UNESCAPED_UNICODE)) ?></code> <code style="font-size:10px;"><?= e(json_encode($rv, JSON_UNESCAPED_UNICODE)) ?></code>
<?php else: ?> <?php else: ?>
...@@ -199,7 +206,7 @@ ...@@ -199,7 +206,7 @@
<div style="display:flex;justify-content:space-between;align-items:center;"> <div style="display:flex;justify-content:space-between;align-items:center;">
<div> <div>
<strong>#<?= (int) $os['id'] ?></strong> <strong>#<?= (int) $os['id'] ?></strong>
<span style="color:#6B7280;font-size:11px;display:block;"><?= e($os['snapshot_reason']) ?></span> <span style="color:#6B7280;font-size:11px;display:block;"><?= e($reasonLabels[$os['snapshot_reason']] ?? $os['snapshot_reason']) ?></span>
</div> </div>
<div style="text-align:left;"> <div style="text-align:left;">
<?php if ((int) $os['id'] !== (int) $snapshot['id']): ?> <?php if ((int) $os['id'] !== (int) $snapshot['id']): ?>
......
...@@ -764,6 +764,11 @@ class DeathController extends Controller ...@@ -764,6 +764,11 @@ class DeathController extends Controller
'status' => 'deceased', 'is_archived' => 1, 'status' => 'deceased', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'), 'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['spouse_id']]); ], '`id` = ?', [(int) $case['spouse_id']]);
// Remove unpaid subscription rows for the deceased spouse
$db->query(
"DELETE FROM subscriptions WHERE member_id = ? AND person_type = 'spouse' AND person_id = ? AND status IN ('pending','overdue')",
[(int) $case['member_id'], (int) $case['spouse_id']]
);
$auditLog[] = 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? ''); $auditLog[] = 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? '');
DeathAuditService::log($caseId, 'spouse_deceased', 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? ''), 'spouses', (int) $case['spouse_id']); DeathAuditService::log($caseId, 'spouse_deceased', 'تم تسجيل وفاة الزوج/ة: ' . ($spouseRow['full_name_ar'] ?? ''), 'spouses', (int) $case['spouse_id']);
...@@ -773,6 +778,11 @@ class DeathController extends Controller ...@@ -773,6 +778,11 @@ class DeathController extends Controller
'status' => 'deceased', 'is_archived' => 1, 'status' => 'deceased', 'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'), 'archived_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $case['child_id']]); ], '`id` = ?', [(int) $case['child_id']]);
// Remove unpaid subscription rows for the deceased child
$db->query(
"DELETE FROM subscriptions WHERE member_id = ? AND person_type = 'child' AND person_id = ? AND status IN ('pending','overdue')",
[(int) $case['member_id'], (int) $case['child_id']]
);
$auditLog[] = 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? ''); $auditLog[] = 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? '');
DeathAuditService::log($caseId, 'child_deceased', 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? ''), 'children', (int) $case['child_id']); DeathAuditService::log($caseId, 'child_deceased', 'تم تسجيل وفاة الابن/الابنة: ' . ($childRow['full_name_ar'] ?? ''), 'children', (int) $case['child_id']);
...@@ -830,6 +840,7 @@ class DeathController extends Controller ...@@ -830,6 +840,7 @@ class DeathController extends Controller
'membership_value' => $currentPlanPrice, 'membership_value' => $currentPlanPrice,
'transferred_from_death_id' => $caseId, 'transferred_from_death_id' => $caseId,
'original_membership_number' => $inheritedNumber, 'original_membership_number' => $inheritedNumber,
'ownership_transfer_date' => date('Y-m-d'),
'form_number' => $formData['form_number'] ?? null, 'form_number' => $formData['form_number'] ?? null,
'deceased_member_name' => $member['full_name_ar'], 'deceased_member_name' => $member['full_name_ar'],
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
...@@ -907,6 +918,7 @@ class DeathController extends Controller ...@@ -907,6 +918,7 @@ class DeathController extends Controller
'phone_mobile' => $secSpouse['mobile'] ?? $member['phone_mobile'] ?? '00000000000', 'phone_mobile' => $secSpouse['mobile'] ?? $member['phone_mobile'] ?? '00000000000',
'membership_value' => $currentPlanPrice, 'membership_value' => $currentPlanPrice,
'transferred_from_death_id' => $caseId, 'transferred_from_death_id' => $caseId,
'ownership_transfer_date' => date('Y-m-d'),
'deceased_member_name' => $member['full_name_ar'], 'deceased_member_name' => $member['full_name_ar'],
'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'),
...@@ -994,8 +1006,9 @@ class DeathController extends Controller ...@@ -994,8 +1006,9 @@ class DeathController extends Controller
// STEP 3: Transfer active financial data to new membership // STEP 3: Transfer active financial data to new membership
$this->transferActiveData($db, (int) $case['member_id'], $newMemberId, $caseId); $this->transferActiveData($db, (int) $case['member_id'], $newMemberId, $caseId);
// STEP 4: Create annual subscriptions for new membership // STEP 4: Create annual subscriptions for new membership (marked as paid — fee collected with death fee)
$this->createNewSubscriptions($db, $newMemberId, $caseId); $deathPaymentId = $deathPaymentCheck ? (int) $deathPaymentCheck['id'] : null;
$this->createNewSubscriptions($db, $newMemberId, $caseId, $deathPaymentId);
ArchiveService::recordNumberTransfer($inheritedNumber, 'death_transfer', 'members', $newMemberId); ArchiveService::recordNumberTransfer($inheritedNumber, 'death_transfer', 'members', $newMemberId);
...@@ -1130,7 +1143,7 @@ class DeathController extends Controller ...@@ -1130,7 +1143,7 @@ class DeathController extends Controller
} }
} }
private function createNewSubscriptions($db, int $newMemberId, int $caseId): void private function createNewSubscriptions($db, int $newMemberId, int $caseId, ?int $paymentId = null): void
{ {
$now = date('Y-m-d H:i:s'); $now = date('Y-m-d H:i:s');
$currentMonth = (int) date('n'); $currentMonth = (int) date('n');
...@@ -1148,8 +1161,11 @@ class DeathController extends Controller ...@@ -1148,8 +1161,11 @@ class DeathController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$createdBy = $employee ? (int) $employee->id : null; $createdBy = $employee ? (int) $employee->id : null;
$paidBy = $employee ? (int) $employee->id : null;
$memberTotal = bcadd($memberSub, $devFee, 2);
// Member subscription // Member subscription (marked as paid — fee included in death transfer payment)
$db->insert('subscriptions', [ $db->insert('subscriptions', [
'member_id' => $newMemberId, 'member_id' => $newMemberId,
'financial_year' => $financialYear, 'financial_year' => $financialYear,
...@@ -1158,16 +1174,19 @@ class DeathController extends Controller ...@@ -1158,16 +1174,19 @@ class DeathController extends Controller
'base_amount' => $memberSub, 'base_amount' => $memberSub,
'development_fee' => $devFee, 'development_fee' => $devFee,
'discount_amount' => '0.00', 'discount_amount' => '0.00',
'total_amount' => bcadd($memberSub, $devFee, 2), 'total_amount' => $memberTotal,
'paid_amount' => '0.00', 'paid_amount' => $memberTotal,
'fine_amount' => '0.00', 'fine_amount' => '0.00',
'status' => 'pending', 'status' => 'paid',
'paid_at' => $now,
'payment_id' => $paymentId,
'paid_by' => $paidBy,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
'created_by' => $createdBy, 'created_by' => $createdBy,
]); ]);
// Spouse subscriptions // Spouse subscriptions (marked as paid)
$spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]); $spouses = $db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($spouses as $s) { foreach ($spouses as $s) {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
...@@ -1180,16 +1199,19 @@ class DeathController extends Controller ...@@ -1180,16 +1199,19 @@ class DeathController extends Controller
'development_fee' => '0.00', 'development_fee' => '0.00',
'discount_amount' => '0.00', 'discount_amount' => '0.00',
'total_amount' => $spouseSub, 'total_amount' => $spouseSub,
'paid_amount' => '0.00', 'paid_amount' => $spouseSub,
'fine_amount' => '0.00', 'fine_amount' => '0.00',
'status' => 'pending', 'status' => 'paid',
'paid_at' => $now,
'payment_id' => $paymentId,
'paid_by' => $paidBy,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
'created_by' => $createdBy, 'created_by' => $createdBy,
]); ]);
} }
// Child subscriptions // Child subscriptions (marked as paid)
$children = $db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]); $children = $db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($children as $c) { foreach ($children as $c) {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
...@@ -1202,16 +1224,19 @@ class DeathController extends Controller ...@@ -1202,16 +1224,19 @@ class DeathController extends Controller
'development_fee' => '0.00', 'development_fee' => '0.00',
'discount_amount' => '0.00', 'discount_amount' => '0.00',
'total_amount' => $childSub, 'total_amount' => $childSub,
'paid_amount' => '0.00', 'paid_amount' => $childSub,
'fine_amount' => '0.00', 'fine_amount' => '0.00',
'status' => 'pending', 'status' => 'paid',
'paid_at' => $now,
'payment_id' => $paymentId,
'paid_by' => $paidBy,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
'created_by' => $createdBy, 'created_by' => $createdBy,
]); ]);
} }
// Temp subscriptions // Temp subscriptions (marked as paid)
$temps = $db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]); $temps = $db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [$newMemberId]);
foreach ($temps as $t) { foreach ($temps as $t) {
$db->insert('subscriptions', [ $db->insert('subscriptions', [
...@@ -1224,9 +1249,12 @@ class DeathController extends Controller ...@@ -1224,9 +1249,12 @@ class DeathController extends Controller
'development_fee' => '0.00', 'development_fee' => '0.00',
'discount_amount' => '0.00', 'discount_amount' => '0.00',
'total_amount' => $tempSub, 'total_amount' => $tempSub,
'paid_amount' => '0.00', 'paid_amount' => $tempSub,
'fine_amount' => '0.00', 'fine_amount' => '0.00',
'status' => 'pending', 'status' => 'paid',
'paid_at' => $now,
'payment_id' => $paymentId,
'paid_by' => $paidBy,
'created_at' => $now, 'created_at' => $now,
'updated_at' => $now, 'updated_at' => $now,
'created_by' => $createdBy, 'created_by' => $createdBy,
......
...@@ -106,8 +106,14 @@ class MemberController extends Controller ...@@ -106,8 +106,14 @@ class MemberController extends Controller
$parsed = NationalIdParser::parse($nationalId); $parsed = NationalIdParser::parse($nationalId);
if (!$parsed['is_valid']) { $errors = array_merge($errors, $parsed['errors']); } if (!$parsed['is_valid']) { $errors = array_merge($errors, $parsed['errors']); }
else { $dob = $parsed['dob']; $ageYears = $parsed['age_years']; $ageMonths = $parsed['age_months']; $gender = $parsed['gender']; $govCode = $parsed['governorate_code']; } else { $dob = $parsed['dob']; $ageYears = $parsed['age_years']; $ageMonths = $parsed['age_months']; $gender = $parsed['gender']; $govCode = $parsed['governorate_code']; }
$dup = $db->selectOne("SELECT id, full_name_ar FROM members WHERE national_id = ? AND is_archived = 0", [$nationalId]); $dup = $db->selectOne("SELECT id, full_name_ar, is_archived FROM members WHERE national_id = ?", [$nationalId]);
if ($dup) $errors[] = 'الرقم القومي مسجل بالفعل: ' . $dup['full_name_ar']; if ($dup) {
if ($dup['is_archived']) {
$errors[] = 'الرقم القومي مسجل في عضوية مؤرشفة: ' . $dup['full_name_ar'];
} else {
$errors[] = 'الرقم القومي مسجل بالفعل: ' . $dup['full_name_ar'];
}
}
} else { } else {
$dob = $request->post('date_of_birth_manual', '') ?: $request->post('date_of_birth', ''); $dob = $request->post('date_of_birth_manual', '') ?: $request->post('date_of_birth', '');
$gender = $request->post('gender_manual', '') ?: $request->post('gender', ''); $gender = $request->post('gender_manual', '') ?: $request->post('gender', '');
......
...@@ -167,34 +167,56 @@ $related = $primarySnapshot['related_data'] ?? []; ...@@ -167,34 +167,56 @@ $related = $primarySnapshot['related_data'] ?? [];
<!-- Subscriptions --> <!-- Subscriptions -->
<?php if (!empty($subscriptions)): ?> <?php if (!empty($subscriptions)): ?>
<?php
$subStatusLabels = ['paid' => 'مدفوع', 'pending' => 'معلق', 'overdue' => 'متأخر', 'exempted' => 'معفى', 'exempt' => 'معفى'];
$subTypeLabels = ['member' => 'العضو', 'spouse' => 'زوج/ة', 'child' => 'ابن/ابنة', 'temporary' => 'مؤقت', 'temp' => 'مؤقت'];
$groupedSubs = [];
foreach ($subscriptions as $sub) {
$fy = $sub['financial_year'] ?? $sub['year'] ?? $sub['subscription_year'] ?? '—';
$groupedSubs[$fy][] = $sub;
}
krsort($groupedSubs);
?>
<div class="card" style="margin-bottom:20px;"> <div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;">الاشتراكات السنوية (<?= count($subscriptions) ?>)</h3> <h3 style="margin:0;color:#7C3AED;">الاشتراكات السنوية (<?= count($subscriptions) ?>)</h3>
</div> </div>
<div style="padding:20px;"> <div style="padding:20px;">
<table class="data-table" style="font-size:12px;width:100%;"> <?php foreach ($groupedSubs as $fy => $yearSubs): ?>
<thead><tr><th>السنة المالية</th><th>النوع</th><th>الاسم</th><th>المبلغ</th><th>المدفوع</th><th>الحالة</th></tr></thead>
<tbody>
<?php foreach ($subscriptions as $sub): ?>
<?php <?php
$yearTotal = array_sum(array_map(fn($s) => (float) ($s['total_amount'] ?? 0), $yearSubs));
$yearPaid = array_sum(array_map(fn($s) => (float) ($s['paid_amount'] ?? 0), $yearSubs));
$allPaid = $yearTotal > 0 && $yearPaid >= $yearTotal;
$yearColor = $allPaid ? '#059669' : '#D97706';
?>
<div style="border:1px solid #E5E7EB;border-radius:8px;margin-bottom:12px;overflow:hidden;">
<div style="padding:10px 15px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:700;color:#1F2937;font-size:14px;"><?= e($fy) ?></span>
<span style="font-size:12px;color:<?= $yearColor ?>;font-weight:600;"><?= $allPaid ? 'مدفوع بالكامل' : 'غير مكتمل' ?><?= money((string) $yearPaid) ?> / <?= money((string) $yearTotal) ?></span>
</div>
<table style="width:100%;font-size:12px;">
<thead><tr style="background:#F3F4F6;"><th style="padding:6px 10px;text-align:right;">النوع</th><th style="padding:6px 10px;text-align:right;">الاسم</th><th style="padding:6px 10px;text-align:right;">المبلغ</th><th style="padding:6px 10px;text-align:right;">المدفوع</th><th style="padding:6px 10px;text-align:right;">الحالة</th></tr></thead>
<tbody>
<?php foreach ($yearSubs as $sub):
$subStatus = $sub['status'] ?? '—'; $subStatus = $sub['status'] ?? '—';
$subColor = match($subStatus) { $subColor = match($subStatus) {
'paid' => '#059669', 'pending' => '#F59E0B', 'overdue' => '#DC2626', 'paid' => '#059669', 'pending' => '#F59E0B', 'overdue' => '#DC2626',
'exempted' => '#6B7280', default => '#374151', 'exempted', 'exempt' => '#6B7280', default => '#374151',
}; };
?> ?>
<tr> <tr style="border-top:1px solid #F3F4F6;">
<td style="font-weight:600;"><?= e($sub['financial_year'] ?? $sub['year'] ?? $sub['subscription_year'] ?? '—') ?></td> <td style="padding:5px 10px;"><?= e($subTypeLabels[$sub['person_type'] ?? ''] ?? ($sub['person_type'] ?? '—')) ?></td>
<td><?= e($sub['person_type'] ?? '—') ?></td> <td style="padding:5px 10px;"><?= e($sub['person_name'] ?? '—') ?></td>
<td><?= e($sub['person_name'] ?? '—') ?></td> <td style="padding:5px 10px;font-weight:600;"><?= money($sub['total_amount'] ?? '0') ?></td>
<td style="font-weight:600;"><?= money($sub['total_amount'] ?? '0') ?></td> <td style="padding:5px 10px;"><?= money($sub['paid_amount'] ?? '0') ?></td>
<td><?= money($sub['paid_amount'] ?? '0') ?></td> <td style="padding:5px 10px;"><span style="color:<?= $subColor ?>;font-weight:600;"><?= e($subStatusLabels[$subStatus] ?? $subStatus) ?></span></td>
<td><span style="color:<?= $subColor ?>;font-weight:600;"><?= e($subStatus) ?></span></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
<?php endforeach; ?>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
......
...@@ -121,6 +121,9 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false)); ...@@ -121,6 +121,9 @@ $canEdit = can('member.edit') && (!$isLocked || ($isSuperAdmin ?? false));
<a href="/members/archive/<?= (int) $waiverTransfer['source_member_id_ref'] ?>" style="background:#FEF3C7;padding:2px 8px;border-radius:10px;font-size:11px;color:#92400E;font-weight:600;text-decoration:none;">&#x1f4e6; عرض العضوية المؤرشفة</a> <a href="/members/archive/<?= (int) $waiverTransfer['source_member_id_ref'] ?>" style="background:#FEF3C7;padding:2px 8px;border-radius:10px;font-size:11px;color:#92400E;font-weight:600;text-decoration:none;">&#x1f4e6; عرض العضوية المؤرشفة</a>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($member->ownership_transfer_date)): ?>
<span style="background:#ECFDF5;padding:2px 8px;border-radius:10px;font-size:11px;">تاريخ انتقال الملكية: <strong style="color:#059669;"><?= arabic_date($member->ownership_transfer_date) ?></strong></span>
<?php endif; ?>
<span>📋 استمارة: <strong style="color:#D97706;"><?= e($member->form_number ?? '—') ?></strong></span> <span>📋 استمارة: <strong style="color:#D97706;"><?= e($member->form_number ?? '—') ?></strong></span>
<span>🏢 <?= e($branchName) ?></span> <span>🏢 <?= e($branchName) ?></span>
<span><?= $member->gender === 'male' ? '👨' : '👩' ?> <?= $member->getGenderLabel() ?></span> <span><?= $member->gender === 'male' ? '👨' : '👩' ?> <?= $member->getGenderLabel() ?></span>
......
...@@ -155,13 +155,23 @@ class Payment extends Model ...@@ -155,13 +155,23 @@ class Payment extends Model
'membership_fee' => 'رسوم عضوية', 'membership_fee' => 'رسوم عضوية',
'form_fee' => 'رسوم استمارة', 'form_fee' => 'رسوم استمارة',
'addition_fee' => 'رسوم إضافة', 'addition_fee' => 'رسوم إضافة',
'annual_subscription'=> 'اشتراك سنوي', 'annual_subscription' => 'اشتراك سنوي',
'fine' => 'غرامة', 'fine' => 'غرامة',
'penalty' => 'عقوبة مالية', 'penalty' => 'عقوبة مالية',
'installment' => 'قسط', 'installment' => 'قسط',
'down_payment' => 'مقدم', 'down_payment' => 'مقدم',
'development_fee' => 'رسوم تنمية', 'development_fee' => 'رسوم تنمية',
'carnet_replacement' => 'بدل فاقد كارنيه', 'carnet_replacement' => 'بدل فاقد كارنيه',
'separation_fee' => 'رسوم فصل',
'transfer_fee' => 'رسوم تحويل',
'death_fee' => 'رسوم وفاة',
'waiver_fee' => 'رسوم تنازل',
'divorce_fee' => 'رسوم طلاق',
'sports_subscription' => 'اشتراك نشاط رياضي',
'seasonal_fee' => 'رسوم عضوية موسمية',
'companion_surcharge' => 'رسوم ملحقين إضافيين',
'reinstatement_fee' => 'رسوم إعادة العضوية',
'sale' => 'مبيعات',
'other' => 'أخرى', 'other' => 'أخرى',
default => $type, default => $type,
}; };
......
...@@ -224,6 +224,110 @@ final class SubscriptionSyncService ...@@ -224,6 +224,110 @@ final class SubscriptionSyncService
} }
} }
/**
* Re-sync subscriptions for a member after membership composition changes.
* Adds missing dependents, removes subscription rows for archived/removed dependents (only unpaid).
*/
public static function refreshForMember(int $memberId): void
{
try {
$db = App::getInstance()->db();
$fy = financial_year();
$member = $db->selectOne(
"SELECT id, membership_type, status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if (!$member || $member['status'] !== 'active') {
return;
}
$exemptTypes = self::getExemptTypes();
if (in_array($member['membership_type'], $exemptTypes, true)) {
return;
}
// Ensure member's own row exists
self::syncForMember($memberId);
// Get current active dependents
$dependentMap = [
'spouse' => 'spouses',
'child' => 'children',
'temporary' => 'temporary_members',
];
$activePersonIds = [];
foreach ($dependentMap as $personType => $table) {
$deps = $db->select(
"SELECT id FROM {$table} WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$memberId]
);
foreach ($deps as $dep) {
$activePersonIds[$personType][] = (int) $dep['id'];
self::syncForDependent($personType, (int) $dep['id'], $memberId);
}
}
// Remove unpaid subscription rows for dependents no longer active
$existingSubs = $db->select(
"SELECT id, person_type, person_id FROM subscriptions WHERE member_id = ? AND financial_year = ? AND person_type != 'member' AND status IN ('pending','overdue')",
[$memberId, $fy]
);
foreach ($existingSubs as $sub) {
$type = $sub['person_type'];
$pid = (int) $sub['person_id'];
if (!in_array($pid, $activePersonIds[$type] ?? [], true)) {
$db->delete('subscriptions', '`id` = ?', [(int) $sub['id']]);
}
}
} catch (\Throwable $e) {
Logger::error("SubscriptionSyncService::refreshForMember failed: " . $e->getMessage(), [
'member_id' => $memberId,
]);
}
}
/**
* Mark all pending/overdue subscriptions for the current FY as paid.
* Used after transfer operations where the annual fee was already collected.
*/
public static function markCurrentYearPaid(int $memberId, ?int $paymentId = null): void
{
try {
$db = App::getInstance()->db();
$fy = financial_year();
$ts = date('Y-m-d H:i:s');
$employee = App::getInstance()->currentEmployee();
$paidBy = $employee ? (int) $employee->id : null;
$pendingSubs = $db->select(
"SELECT id, total_amount, fine_amount, development_fee FROM subscriptions WHERE member_id = ? AND financial_year = ? AND status IN ('pending','overdue')",
[$memberId, $fy]
);
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,
'payment_id' => $paymentId,
'status' => 'paid',
'paid_at' => $ts,
'paid_by' => $paidBy,
'updated_at' => $ts,
], '`id` = ?', [(int) $sub['id']]);
}
} catch (\Throwable $e) {
Logger::error("SubscriptionSyncService::markCurrentYearPaid failed: " . $e->getMessage(), [
'member_id' => $memberId,
]);
}
}
private static function getExemptTypes(): array private static function getExemptTypes(): array
{ {
try { try {
......
...@@ -80,3 +80,34 @@ EventBus::listen('temporary.added', function (array $data) { ...@@ -80,3 +80,34 @@ EventBus::listen('temporary.added', function (array $data) {
} }
} }
}); });
// ─── Sync subscriptions after membership transfers ──────────────────────────
// Child separation / full transfer: create subscription rows + mark as paid (fee was collected upfront)
EventBus::listen('transfer.completed', function (array $data) {
$targetMemberId = (int) ($data['target_member_id'] ?? 0);
if ($targetMemberId) {
SubscriptionSyncService::syncForMember($targetMemberId);
// Find the payment linked to this transfer
$db = \App\Core\App::getInstance()->db();
$payment = $db->selectOne(
"SELECT id FROM payments WHERE related_entity_type = 'transfer_requests' AND related_entity_id = ? AND is_voided = 0 ORDER BY id DESC LIMIT 1",
[(int) ($data['transfer_id'] ?? 0)]
);
$paymentId = $payment ? (int) $payment['id'] : null;
SubscriptionSyncService::markCurrentYearPaid($targetMemberId, $paymentId);
}
// Refresh source member (remove separated child/spouse subscriptions)
$sourceMemberId = (int) ($data['source_member_id'] ?? 0);
if ($sourceMemberId) {
SubscriptionSyncService::refreshForMember($sourceMemberId);
}
});
// After death transfer: refresh the new membership's subscriptions
EventBus::listen('death.completed', function (array $data) {
$newMemberId = (int) ($data['new_member_id'] ?? $data['target_member_id'] ?? 0);
if ($newMemberId) {
SubscriptionSyncService::refreshForMember($newMemberId);
}
});
\ No newline at end of file
...@@ -73,6 +73,7 @@ final class WaiverProcessor ...@@ -73,6 +73,7 @@ final class WaiverProcessor
'activated_by_payment_id' => $waiverPaymentId, 'activated_by_payment_id' => $waiverPaymentId,
'activated_at' => date('Y-m-d H:i:s'), 'activated_at' => date('Y-m-d H:i:s'),
'transferred_from_waiver_id' => $waiverId, 'transferred_from_waiver_id' => $waiverId,
'ownership_transfer_date' => date('Y-m-d'),
'waived_from_member_name' => $sourceMember['full_name_ar'] ?? null, 'waived_from_member_name' => $sourceMember['full_name_ar'] ?? null,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $waiver['target_member_id']]); ], '`id` = ?', [(int) $waiver['target_member_id']]);
...@@ -915,14 +916,16 @@ final class WaiverProcessor ...@@ -915,14 +916,16 @@ final class WaiverProcessor
} }
/** /**
* Transfer all active data from source member to target member. * Transfer active data from source member to target member.
* Called AFTER snapshot is taken so the archive preserves original state. * Called AFTER snapshot is taken so the archive preserves original state.
* Payments and paid subscriptions stay with the archived source (historical record).
* Only pending obligations and active dependents move to the new owner.
*/ */
private static function transferActiveData($db, int $sourceId, int $targetId): void private static function transferActiveData($db, int $sourceId, int $targetId): void
{ {
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
// Dependents // Dependents (only active/non-archived)
$db->query( $db->query(
"UPDATE spouses SET member_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0", "UPDATE spouses SET member_id = ?, updated_at = ? WHERE member_id = ? AND is_archived = 0",
[$targetId, $ts, $sourceId] [$targetId, $ts, $sourceId]
...@@ -936,25 +939,25 @@ final class WaiverProcessor ...@@ -936,25 +939,25 @@ final class WaiverProcessor
[$targetId, $ts, $sourceId] [$targetId, $ts, $sourceId]
); );
// Financial records // Subscriptions: only pending/overdue move (paid/exempt are historical → stay with archived source)
$db->query( $db->query(
"UPDATE payments SET member_id = ? WHERE member_id = ?", "UPDATE subscriptions SET member_id = ? WHERE member_id = ? AND status IN ('pending','overdue')",
[$targetId, $sourceId]
);
$db->query(
"UPDATE subscriptions SET member_id = ? WHERE member_id = ?",
[$targetId, $sourceId] [$targetId, $sourceId]
); );
// Active installment plans move to new owner
$db->query( $db->query(
"UPDATE installment_plans SET member_id = ? WHERE member_id = ?", "UPDATE installment_plans SET member_id = ? WHERE member_id = ? AND status = 'active'",
[$targetId, $sourceId] [$targetId, $sourceId]
); );
// Unpaid fines move to new owner
$db->query( $db->query(
"UPDATE fines SET member_id = ? WHERE member_id = ?", "UPDATE fines SET member_id = ? WHERE member_id = ? AND status IN ('imposed','appealed')",
[$targetId, $sourceId] [$targetId, $sourceId]
); );
// Documents // Documents (active ones)
$db->query( $db->query(
"UPDATE documents SET member_id = ? WHERE member_id = ? AND is_archived = 0", "UPDATE documents SET member_id = ? WHERE member_id = ? AND is_archived = 0",
[$targetId, $sourceId] [$targetId, $sourceId]
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'members' AND column_name = 'ownership_transfer_date'"
);
if (!$exists) {
$db->raw("ALTER TABLE members ADD COLUMN ownership_transfer_date DATE NULL DEFAULT NULL COMMENT 'Date membership ownership was transferred' AFTER activated_at");
}
};
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