Commit c2e440e4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Enforce waiver bylaws: dependent count validation + annual renewal check

Per bylaws requirements for التنازل عن العضوية:

1. Target member's dependents must NOT exceed source member's original
   dependent count — blocks with error if exceeded, requiring board
   approval + extra fees before proceeding.

2. All annual subscriptions must be paid (no pending/overdue) before
   the waiver can be completed.

3. Show view now displays dependent counts and renewal status.

4. Create view updated with full bylaws summary (5 conditions).

5. Displays current membership value (from pricing_configs) not old stored value.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 6c3db858
......@@ -42,10 +42,11 @@ class WaiverController extends Controller
$totalDependents = $spouseCount + $childCount + $tempCount;
return $this->view('Waiver.Views.create', [
'member' => $member,
'waiver_pct' => $waiverPct,
'waiver_fee' => $waiverFee,
'total_dependents'=> $totalDependents,
'member' => $member,
'membership_value' => $membershipValue,
'waiver_pct' => $waiverPct,
'waiver_fee' => $waiverFee,
'total_dependents' => $totalDependents,
]);
}
......@@ -212,17 +213,54 @@ class WaiverController extends Controller
public function complete(Request $request, string $id): Response
{
// The target member must be created first (via member creation flow)
// and linked to the waiver
$db = App::getInstance()->db();
$waiver = $db->selectOne("SELECT * FROM waiver_requests WHERE id = ?", [(int) $id]);
if (!$waiver) return $this->redirect('/waivers')->withError('الطلب غير موجود');
$targetMemberId = (int) $request->post('target_member_id', 0);
if ($targetMemberId > 0) {
$db->update('waiver_requests', [
'target_member_id' => $targetMemberId,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
} else {
$targetMemberId = (int) ($waiver['target_member_id'] ?? 0);
}
if ($targetMemberId <= 0) {
return $this->redirect("/waivers/{$id}")->withError('يجب تحديد العضو المتنازل إليه أولاً');
}
// Validate: annual renewal must be paid before completing waiver
$unpaidSubs = $db->selectOne(
"SELECT COUNT(*) as cnt FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue')",
[(int) $waiver['source_member_id']]
);
if ((int) ($unpaidSubs['cnt'] ?? 0) > 0) {
return $this->redirect("/waivers/{$id}")->withError('يجب سداد جميع الاشتراكات السنوية المستحقة قبل إتمام التنازل');
}
// Validate: target member's dependents must not exceed source's original count
$originalDependentCount = (int) $waiver['original_dependent_count'];
$targetSpouses = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM spouses WHERE member_id = ? AND is_archived = 0", [$targetMemberId])['cnt'] ?? 0);
$targetChildren = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0", [$targetMemberId])['cnt'] ?? 0);
$targetTemps = (int) ($db->selectOne("SELECT COUNT(*) as cnt FROM temporary_members WHERE member_id = ? AND is_archived = 0", [$targetMemberId])['cnt'] ?? 0);
$targetDependentCount = $targetSpouses + $targetChildren + $targetTemps;
if ($targetDependentCount > $originalDependentCount) {
$excess = $targetDependentCount - $originalDependentCount;
return $this->redirect("/waivers/{$id}")->withError(
'عدد التابعين للعضو المتنازل إليه (' . $targetDependentCount . ') يتجاوز عدد التابعين الأصليين (' . $originalDependentCount . ') بمقدار ' . $excess . ' — يجب موافقة مجلس الأمناء على الإضافة الزائدة واحتساب الرسوم الإضافية قبل إتمام التنازل'
);
}
// Store actual dependent count on the waiver
$db->update('waiver_requests', [
'new_dependent_count' => $targetDependentCount,
'annual_renewal_paid' => 1,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
$result = WaiverProcessor::execute((int) $id);
if (!$result['success']) return $this->redirect("/waivers/{$id}")->withError($result['error']);
......
......@@ -3,12 +3,19 @@
<?php $__template->section('content'); ?>
<div class="card" style="padding:20px;margin-bottom:20px;">
<div style="padding:15px;background:#FFF7ED;border:1px solid #FED7AA;border-radius:8px;margin-bottom:20px;">
<strong style="color:#D97706;">⚠ التنازل عن العضوية:</strong> يتطلب موافقة مجلس الأمناء. العضو المستفيد يحصل على نفس رقم العضوية. عدد التابعين الجدد لا يتجاوز عدد التابعين الأصليين.
<strong style="color:#D97706;">⚠ التنازل عن العضوية — ضوابط اللائحة:</strong>
<ul style="margin:8px 0 0;padding-right:20px;font-size:13px;color:#92400E;line-height:1.8;">
<li>يتطلب موافقة مجلس الأمناء</li>
<li>العضو المستفيد يحصل على <strong>نفس رقم العضوية</strong></li>
<li>عدد التابعين للمتنازل إليه لا يتجاوز عدد التابعين الأصليين (<?= (int) $total_dependents ?>) — أي زيادة تتطلب رسوم إضافية يحددها مجلس الأمناء</li>
<li>يجب سداد جميع الاشتراكات السنوية المستحقة قبل إتمام التنازل</li>
<li>يجب تقديم استمارة عضوية جديدة للمتنازل إليه</li>
</ul>
</div>
<table style="width:100%;max-width:500px;font-size:14px;">
<tr><td style="padding:6px 0;color:#6B7280;">العضو</td><td style="padding:6px 0;font-weight:600;"><?= e($member['full_name_ar']) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">رقم العضوية</td><td style="padding:6px 0;font-weight:700;"><?= e($member['membership_number'] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">قيمة العضوية</td><td style="padding:6px 0;"><?= money($member['membership_value'] ?? '0') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">قيمة العضوية الحالية</td><td style="padding:6px 0;font-weight:600;"><?= money($membership_value) ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">نسبة التنازل</td><td style="padding:6px 0;"><?= e($waiver_pct) ?>%</td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">عدد التابعين</td><td style="padding:6px 0;"><?= (int) $total_dependents ?></td></tr>
<tr style="border-top:2px solid #0D7377;"><td style="padding:10px 0;font-weight:700;font-size:16px;">رسوم التنازل</td><td style="padding:10px 0;font-weight:700;font-size:18px;color:#0D7377;"><?= money($waiver_fee) ?></td></tr>
......
......@@ -6,7 +6,10 @@
<tr><td style="padding:6px 0;color:#6B7280;width:35%;">المتنازل</td><td style="padding:6px 0;"><a href="/members/<?= (int) $waiver['source_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['source_name'] ?? '') ?></a></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">رقم العضوية</td><td style="padding:6px 0;font-weight:700;"><?= e($waiver['membership_number'] ?? '—') ?></td></tr>
<tr><td style="padding:6px 0;color:#6B7280;">الحالة</td><td style="padding:6px 0;font-weight:700;color:<?= match($waiver['status']) { 'completed' => '#059669', 'fee_paid' => '#2563EB', 'approved' => '#0284C7', 'rejected' => '#DC2626', default => '#D97706' } ?>;"><?= match($waiver['status']) { 'requested' => 'مقدم', 'approved' => 'معتمد — في انتظار الدفع', 'fee_paid' => 'تم الدفع', 'completed' => 'مكتمل', 'rejected' => 'مرفوض', default => $waiver['status'] } ?></td></tr>
<?php if ($waiver['target_member_id']): ?><tr><td style="padding:6px 0;color:#6B7280;">المتنازل إليه</td><td style="padding:6px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;">عضو #<?= (int) $waiver['target_member_id'] ?></a></td></tr><?php endif; ?>
<?php if ($waiver['target_member_id']): ?><tr><td style="padding:6px 0;color:#6B7280;">المتنازل إليه</td><td style="padding:6px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['target_name'] ?? '') ?> (عضو #<?= (int) $waiver['target_member_id'] ?>)</a></td></tr><?php endif; ?>
<tr><td style="padding:6px 0;color:#6B7280;">عدد التابعين الأصلي</td><td style="padding:6px 0;"><?= (int) ($waiver['original_dependent_count'] ?? 0) ?> (الحد الأقصى المسموح للمتنازل إليه)</td></tr>
<?php if ($waiver['new_dependent_count'] !== null): ?><tr><td style="padding:6px 0;color:#6B7280;">عدد تابعين المتنازل إليه</td><td style="padding:6px 0;"><?= (int) $waiver['new_dependent_count'] ?></td></tr><?php endif; ?>
<tr><td style="padding:6px 0;color:#6B7280;">سداد التجديد السنوي</td><td style="padding:6px 0;"><?= $waiver['annual_renewal_paid'] ? '<span style="color:#059669;">✅ تم السداد</span>' : '<span style="color:#D97706;">⏳ لم يُسدد بعد</span>' ?></td></tr>
</table>
</div>
......
# Waiver Module — Architecture Map
> **Last updated:** 2026-06-10
> **Last updated:** 2026-06-19 (fixed duplicate key error + fee uses current pricing)
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -98,13 +98,13 @@ requested → approved → fee_paid → completed
```
1. GET /waivers/create/{memberId}
- Validate member exists and is not archived
- Calculate waiver fee: WAIVER_FEE percentage × membership_value
- Calculate waiver fee: WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
- Count dependents (spouses + children + temporary)
- Display fee and dependent summary
2. POST /waivers/store/{memberId}
- Validate: member exists, has a membership_number
- Calculate fee: WAIVER_FEE (default 30%) × membership_value
- Calculate fee: WAIVER_FEE (default 30%) × current membership value (from pricing_configs, NOT stored membership_value)
- Count all active dependents (spouses + children + temp)
- Optionally accept target_member_id
- Create WaiverRequest (status='requested')
......@@ -129,26 +129,37 @@ requested → approved → fee_paid → completed
6. POST /waivers/{id}/complete
- Optionally update target_member_id from POST data
- PRE-VALIDATION (before processor runs):
a. Target member must be set
b. All annual subscriptions for source member must be paid (no pending/overdue)
c. Target member's dependent count must NOT exceed source's original_dependent_count
- If exceeded: blocks with error, requires board approval + extra fees first
d. Records new_dependent_count and marks annual_renewal_paid = 1
- Execute WaiverProcessor::execute():
a. Validate: status must be 'approved' or 'fee_paid'
b. Validate: target_member_id must be set
c. Transaction:
- Archive snapshot of source member
- Archive snapshot of source member (full data preserved in archive_snapshots)
- Archive source member FIRST (number=NULL, status='waived', is_archived=1) — releases unique constraint
- Transfer membership_number to target member (set number, status='active')
- Archive source member (number=NULL, status='waived', is_archived=1)
- Record number chain via ArchiveService
- Status → 'completed'
d. Dispatch: waiver.completed
- POST-STATE:
- Source member: archived, browsable via Archive module
- Target member: active, owns the membership number
- All historical data preserved (no hard deletes)
```
### 5.2 Fee Calculation
```
Waiver Fee = WAIVER_FEE percentage × membership_value_at_waiver
Waiver Fee = WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
Default: 30% × membership_value
Default: 30% × current_price_for(branch_id, qualification_id, membership_type)
Example: 30% × 150,000 = 45,000 EGP
Falls back to stored membership_value only if no matching pricing_configs row exists.
No additional form fee or annual subscription (unlike Transfers/Death/Divorce).
```
......
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