Commit ecb53aca authored by Fares's avatar Fares

fix(waiver): fix auto-completion SQL error, debt calculation, add form_number, block children >21

- Fix critical SQL error: ArchiveService queried installment_cheques ORDER BY
  due_date but actual column is cheque_date (broke auto-completion flow)
- Include development_fee in debt table calculation (was missing 35 EGP per sub)
- Add form_number field to waiver requests (migration + create/show views)
- Block children over 21 from being transferred as children (must be temporary)
- Add board approval and age checks to auto-complete status indicator
- Update architecture map to reflect that dependents ARE transferred
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 74f7db17
...@@ -143,7 +143,7 @@ final class ArchiveService ...@@ -143,7 +143,7 @@ final class ArchiveService
if (!empty($planIds)) { if (!empty($planIds)) {
$placeholders = implode(',', array_fill(0, count($planIds), '?')); $placeholders = implode(',', array_fill(0, count($planIds), '?'));
$related['installment_cheques'] = $db->select( $related['installment_cheques'] = $db->select(
"SELECT * FROM installment_cheques WHERE installment_plan_id IN ({$placeholders}) ORDER BY due_date ASC", "SELECT * FROM installment_cheques WHERE installment_plan_id IN ({$placeholders}) ORDER BY cheque_date ASC",
$planIds $planIds
); );
} }
......
...@@ -198,7 +198,7 @@ class MemberArchiveController extends Controller ...@@ -198,7 +198,7 @@ class MemberArchiveController extends Controller
} }
if (empty($installmentCheques)) { if (empty($installmentCheques)) {
$installmentCheques = $db->select( $installmentCheques = $db->select(
"SELECT * FROM installment_cheques WHERE installment_plan_id IN ({$placeholders}) ORDER BY due_date ASC", "SELECT * FROM installment_cheques WHERE installment_plan_id IN ({$placeholders}) ORDER BY cheque_date ASC",
$planIds $planIds
); );
} }
......
...@@ -100,6 +100,7 @@ class WaiverController extends Controller ...@@ -100,6 +100,7 @@ class WaiverController extends Controller
'source_member_id' => (int) $memberId, 'source_member_id' => (int) $memberId,
'target_member_id' => $targetMemberId > 0 ? $targetMemberId : null, 'target_member_id' => $targetMemberId > 0 ? $targetMemberId : null,
'membership_number' => $member['membership_number'], 'membership_number' => $member['membership_number'],
'form_number' => trim($request->post('form_number', '')) ?: null,
'membership_value_at_waiver' => $membershipValue, 'membership_value_at_waiver' => $membershipValue,
'waiver_fee_percentage' => $waiverPct, 'waiver_fee_percentage' => $waiverPct,
'waiver_fee_amount' => $waiverFee, 'waiver_fee_amount' => $waiverFee,
......
...@@ -16,7 +16,7 @@ class WaiverRequest extends Model ...@@ -16,7 +16,7 @@ class WaiverRequest extends Model
protected static bool $dispatchEvents = true; protected static bool $dispatchEvents = true;
protected static array $fillable = [ protected static array $fillable = [
'source_member_id', 'target_member_id', 'membership_number', 'source_member_id', 'target_member_id', 'membership_number', 'form_number',
'membership_value_at_waiver', 'waiver_fee_percentage', 'waiver_fee_amount', 'membership_value_at_waiver', 'waiver_fee_percentage', 'waiver_fee_amount',
'original_dependent_count', 'original_spouses_count', 'original_children_count', 'original_temporary_count', 'original_dependent_count', 'original_spouses_count', 'original_children_count', 'original_temporary_count',
'new_dependent_count', 'new_spouses_count', 'new_children_count', 'new_temporary_count', 'new_dependent_count', 'new_spouses_count', 'new_children_count', 'new_temporary_count',
......
...@@ -31,6 +31,17 @@ final class WaiverProcessor ...@@ -31,6 +31,17 @@ final class WaiverProcessor
$targetMember = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $waiver['target_member_id']]); $targetMember = $db->selectOne("SELECT * FROM members WHERE id = ?", [(int) $waiver['target_member_id']]);
if (!$targetMember) return ['success' => false, 'error' => 'العضو المستفيد غير موجود']; if (!$targetMember) return ['success' => false, 'error' => 'العضو المستفيد غير موجود'];
// Validate no children over 21 on target member (must be temporary members instead)
$overAgeChildren = $db->select(
"SELECT id, full_name_ar, date_of_birth, TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) as age_years
FROM children WHERE member_id = ? AND is_archived = 0 AND TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) > 21",
[(int) $targetMember['id']]
);
if (!empty($overAgeChildren)) {
$names = array_map(fn($c) => $c['full_name_ar'] . ' (' . $c['age_years'] . ' سنة)', $overAgeChildren);
return ['success' => false, 'error' => 'يوجد أبناء تجاوزوا 21 سنة ويجب تسجيلهم كأعضاء مؤقتين: ' . implode('، ', $names)];
}
$db->beginTransaction(); $db->beginTransaction();
try { try {
$snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId); $snapshotId = ArchiveService::takeSnapshot('members', (int) $sourceMember['id'], 'waiver', 'تنازل — طلب #' . $waiverId);
...@@ -235,8 +246,8 @@ final class WaiverProcessor ...@@ -235,8 +246,8 @@ final class WaiverProcessor
// 1. Subscriptions (covers member + all dependents via person_type/person_id) // 1. Subscriptions (covers member + all dependents via person_type/person_id)
$subs = $db->select( $subs = $db->select(
"SELECT id, person_type, person_id, person_name, financial_year, (total_amount - paid_amount + fine_amount) as due "SELECT id, person_type, person_id, person_name, financial_year, (total_amount - paid_amount + fine_amount + COALESCE(development_fee, 0)) as due
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue') AND (total_amount - paid_amount + fine_amount) > 0", FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue') AND (total_amount - paid_amount + fine_amount + COALESCE(development_fee, 0)) > 0",
[$memberId] [$memberId]
); );
foreach ($subs as $sub) { foreach ($subs as $sub) {
......
...@@ -138,6 +138,14 @@ ...@@ -138,6 +138,14 @@
<form method="POST" action="/waivers/store/<?= (int) $member['id'] ?>" id="waiver-form" enctype="multipart/form-data"> <form method="POST" action="/waivers/store/<?= (int) $member['id'] ?>" id="waiver-form" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<!-- Form Number -->
<div class="card" style="padding:20px;margin-bottom:15px;">
<div class="form-group">
<label class="form-label">رقم الاستمارة</label>
<input type="text" name="form_number" class="form-input" placeholder="أدخل رقم استمارة التنازل" value="<?= e(old('form_number') ?? '') ?>">
</div>
</div>
<!-- Documents --> <!-- Documents -->
<div class="card" style="padding:20px;margin-bottom:15px;"> <div class="card" style="padding:20px;margin-bottom:15px;">
<h4 style="margin:0 0 15px;color:#374151;">📄 المستندات المطلوبة</h4> <h4 style="margin:0 0 15px;color:#374151;">📄 المستندات المطلوبة</h4>
......
...@@ -114,6 +114,9 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => ' ...@@ -114,6 +114,9 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<table style="width:100%;max-width:700px;font-size:14px;"> <table style="width:100%;max-width:700px;font-size:14px;">
<tr><td style="padding:8px 0;color:#6B7280;width:30%;">المتنازل</td><td style="padding:8px 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:8px 0;color:#6B7280;width:30%;">المتنازل</td><td style="padding:8px 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:8px 0;color:#6B7280;">رقم العضوية</td><td style="padding:8px 0;font-weight:700;font-size:16px;"><?= e($waiver['membership_number'] ?? '—') ?></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">رقم العضوية</td><td style="padding:8px 0;font-weight:700;font-size:16px;"><?= e($waiver['membership_number'] ?? '—') ?></td></tr>
<?php if (!empty($waiver['form_number'])): ?>
<tr><td style="padding:8px 0;color:#6B7280;">رقم الاستمارة</td><td style="padding:8px 0;font-weight:600;"><?= e($waiver['form_number']) ?></td></tr>
<?php endif; ?>
<?php if ($waiver['target_member_id']): ?> <?php if ($waiver['target_member_id']): ?>
<tr><td style="padding:8px 0;color:#6B7280;">المتنازل إليه (المشتري)</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['target_name'] ?? '') ?></a></td></tr> <tr><td style="padding:8px 0;color:#6B7280;">المتنازل إليه (المشتري)</td><td style="padding:8px 0;"><a href="/members/<?= (int) $waiver['target_member_id'] ?>" style="color:#0D7377;font-weight:600;"><?= e($waiver['target_name'] ?? '') ?></a></td></tr>
<?php endif; ?> <?php endif; ?>
...@@ -508,9 +511,10 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => ' ...@@ -508,9 +511,10 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
'under_12' => '#059669', '12_to_16' => '#0284C7', '16_to_18' => '#D97706', '18_to_25' => '#EA580C', '25_plus' => '#DC2626', default => '#6B7280' 'under_12' => '#059669', '12_to_16' => '#0284C7', '16_to_18' => '#D97706', '18_to_25' => '#EA580C', '25_plus' => '#DC2626', default => '#6B7280'
}; };
$is25Plus = ($person['age_category_code'] ?? '') === '25_plus'; $is25Plus = ($person['age_category_code'] ?? '') === '25_plus';
$is21Plus = ($person['age_years'] ?? 0) > 21;
$is18Plus = in_array($person['age_category_code'] ?? '', ['18_to_25', '25_plus']); $is18Plus = in_array($person['age_category_code'] ?? '', ['18_to_25', '25_plus']);
?> ?>
<div style="padding:14px;background:<?= $is25Plus ? '#FEF2F2' : ($isExcess ? '#FFFBEB' : '#F9FAFB') ?>;border:1px solid <?= $is25Plus ? '#FECACA' : ($isExcess ? '#FDE68A' : '#E5E7EB') ?>;border-radius:8px;margin-bottom:10px;" class="ind-fee-card" data-idx="<?= $indIdx ?>"> <div style="padding:14px;background:<?= $is21Plus ? '#FEF2F2' : ($isExcess ? '#FFFBEB' : '#F9FAFB') ?>;border:1px solid <?= $is21Plus ? '#FECACA' : ($isExcess ? '#FDE68A' : '#E5E7EB') ?>;border-radius:8px;margin-bottom:10px;" class="ind-fee-card" data-idx="<?= $indIdx ?>">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;flex-wrap:wrap;">
<strong style="color:#374151;font-size:14px;"><?= e($person['person_name']) ?></strong> <strong style="color:#374151;font-size:14px;"><?= e($person['person_name']) ?></strong>
<?php if ($person['date_of_birth']): ?> <?php if ($person['date_of_birth']): ?>
...@@ -529,10 +533,9 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => ' ...@@ -529,10 +533,9 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<span style="background:#ECFDF5;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;">ضمن العدد الأصلي</span> <span style="background:#ECFDF5;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;">ضمن العدد الأصلي</span>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php if ($is25Plus): ?> <?php if ($is21Plus): ?>
<div style="padding:8px 12px;background:#FEE2E2;border:1px solid #FECACA;border-radius:6px;margin-bottom:10px;display:flex;align-items:center;gap:8px;"> <div style="padding:8px 12px;background:#FEE2E2;border:1px solid #FECACA;border-radius:6px;margin-bottom:10px;display:flex;align-items:center;gap:8px;">
<span style="color:#DC2626;font-size:12px;font-weight:700;">⚠ تجاوز 25 سنة — قد يتطلب فصل العضوية</span> <span style="color:#DC2626;font-size:12px;font-weight:700;">🚫 تجاوز 21 سنة — لا يمكن تسجيله كابن/ابنة، يجب تحويله لعضو مؤقت قبل إتمام التنازل</span>
<a href="/members/<?= (int) $waiver['target_member_id'] ?>" class="btn btn-outline" style="padding:4px 10px;font-size:11px;color:#DC2626;border-color:#DC2626;margin-right:auto;">فصل العضوية</a>
</div> </div>
<?php elseif ($is18Plus): ?> <?php elseif ($is18Plus): ?>
<div style="padding:6px 12px;background:#FEF3C7;border:1px solid #FDE68A;border-radius:6px;margin-bottom:10px;font-size:11px;color:#92400E;"> <div style="padding:6px 12px;background:#FEF3C7;border:1px solid #FDE68A;border-radius:6px;margin-bottom:10px;font-size:11px;color:#92400E;">
...@@ -757,9 +760,28 @@ function updateSummary() { ...@@ -757,9 +760,28 @@ function updateSummary() {
<?php elseif ($waiver['target_member_id']): ?> <?php elseif ($waiver['target_member_id']): ?>
<li style="margin-bottom:4px;">✅ لا مديونيات على المتنازل إليه</li> <li style="margin-bottom:4px;">✅ لا مديونيات على المتنازل إليه</li>
<?php endif; ?> <?php endif; ?>
<?php if (!$waiver['approved_by']): ?>
<li style="margin-bottom:4px;color:#DC2626;font-weight:700;">❌ لم يتم اعتماد مجلس الأمناء بعد</li>
<?php else: ?>
<li style="margin-bottom:4px;">✅ تم اعتماد مجلس الأمناء</li>
<?php endif; ?>
<?php
$overAgeChildren = [];
if ($waiver['target_member_id']) {
$overAgeChildren = App\Core\App::getInstance()->db()->select(
"SELECT full_name_ar, TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) as age_years FROM children WHERE member_id = ? AND is_archived = 0 AND TIMESTAMPDIFF(YEAR, date_of_birth, CURDATE()) > 21",
[(int) $waiver['target_member_id']]
);
}
?>
<?php if (!empty($overAgeChildren)): ?>
<li style="margin-bottom:4px;color:#DC2626;font-weight:700;">❌ يوجد أبناء تجاوزوا 21 سنة (يجب تحويلهم لأعضاء مؤقتين): <?= implode('، ', array_map(fn($c) => e($c['full_name_ar']) . ' (' . $c['age_years'] . ' سنة)', $overAgeChildren)) ?></li>
<?php else: ?>
<li style="margin-bottom:4px;">✅ لا يوجد أبناء تجاوزوا 21 سنة</li>
<?php endif; ?>
</ul> </ul>
<?php <?php
$allClear = $waiver['target_member_id'] && $source_debt_check['clear'] && (!$target_debt_check || $target_debt_check['clear']); $allClear = $waiver['target_member_id'] && $waiver['approved_by'] && $source_debt_check['clear'] && (!$target_debt_check || $target_debt_check['clear']) && empty($overAgeChildren);
?> ?>
<?php if ($allClear && can('waiver.approve')): ?> <?php if ($allClear && can('waiver.approve')): ?>
<div style="margin-top:15px;padding:12px;background:#ECFDF5;border:1px solid #BBF7D0;border-radius:8px;"> <div style="margin-top:15px;padding:12px;background:#ECFDF5;border:1px solid #BBF7D0;border-radius:8px;">
......
<?php
declare(strict_types=1);
return [
'up' => "ALTER TABLE `waiver_requests` ADD COLUMN `form_number` VARCHAR(50) NULL AFTER `membership_number`",
'down' => "ALTER TABLE `waiver_requests` DROP COLUMN `form_number`",
];
# Waiver Module — Architecture Map # Waiver Module — Architecture Map
> **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) > **Last updated:** 2026-07-23 (Round 5: fix installment_cheques SQL error, include dev fee in debt calc, add form_number, block children >21, fix dependency transfer documentation)
> **Status:** Living document — incrementally updated as new information is discovered > **Status:** Living document — incrementally updated as new information is discovered
--- ---
...@@ -341,15 +341,14 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**: ...@@ -341,15 +341,14 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**:
### 11.3 Source Member Archival ### 11.3 Source Member Archival
- Source member is permanently archived (is_archived=1, status='waived') - Source member is permanently archived (is_archived=1, status='waived')
- The `archived_by` field is set to the current employee - The `archived_by` field is set to the current employee
- All dependents of source member are NOT transferred (stay orphaned on archived member) - All dependents ARE transferred to target member via `transferActiveData()` before archival
- This differs from full_transfer in Transfers module which moves dependents
### 11.4 No Dependent Transfer ### 11.4 Dependent Transfer (CORRECTED 2026-07-23)
- Unlike TransferProcessor which moves spouses/children/temp to new member - `WaiverProcessor::execute()` calls `transferActiveData()` which moves ALL active:
- WaiverProcessor ONLY transfers the membership number - spouses, children, temporary_members (non-archived)
- Source member's dependents remain attached to the archived source member - payments, subscriptions, installment_plans, fines, documents, payment_requests
- They effectively become orphaned — no active parent member - All are transferred from source to target via `UPDATE ... SET member_id = targetId`
- `original_dependent_count` is recorded for audit but dependents are not migrated - Children over 21 are now BLOCKED from transfer (must be temporary members)
### 11.5 Payment Before Approval Race ### 11.5 Payment Before Approval Race
- Payment request is sent to Cashier queue immediately on creation (before board approval) - Payment request is sent to Cashier queue immediately on creation (before board approval)
...@@ -372,7 +371,7 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**: ...@@ -372,7 +371,7 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**:
2. **Menu in Members module**: Waiver menu item registered in `Members/bootstrap.php` 2. **Menu in Members module**: Waiver menu item registered in `Members/bootstrap.php`
3. **Target must pre-exist**: Must create new member FIRST, then link to waiver (unlike other modules) 3. **Target must pre-exist**: Must create new member FIRST, then link to waiver (unlike other modules)
4. **Same number transfer**: Source's number goes directly to target (like Death primary, unlike Divorce) 4. **Same number transfer**: Source's number goes directly to target (like Death primary, unlike Divorce)
5. **Dependents NOT transferred**: Only the number moves, NOT spouses/children/temp members 5. **Dependents ARE transferred**: `transferActiveData()` moves all active spouses/children/temp + financial records. Children > 21 blocked.
6. **30% default fee**: Configurable via WAIVER_FEE rule 6. **30% default fee**: Configurable via WAIVER_FEE rule
7. **No form fee or annual sub**: Simpler fee structure than Transfers/Death/Divorce 7. **No form fee or annual sub**: Simpler fee structure than Transfers/Death/Divorce
8. **Payment sent immediately**: Cashier queue payment is created at request time (before approval) 8. **Payment sent immediately**: Cashier queue payment is created at request time (before approval)
...@@ -402,7 +401,7 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**: ...@@ -402,7 +401,7 @@ The Waiver module uses permissions registered in **Transfers/bootstrap.php**:
| Source archived? | Yes | Yes | No | Yes | | Source archived? | Yes | Yes | No | Yes |
| New member created? | Yes (in module) | Yes (in module) | Yes (in module) | No (pre-exists) | | New member created? | Yes (in module) | Yes (in module) | Yes (in module) | No (pre-exists) |
| Number inheritance | Same number | Same number (primary) | NEW number | Same number | | Number inheritance | Same number | Same number (primary) | NEW number | Same number |
| Dependents moved? | Yes (full_transfer) | Yes (configurable) | No | No | | Dependents moved? | Yes (full_transfer) | Yes (configurable) | No | Yes (all active) |
| Fee basis | Tiered % by years | Fixed (form+sub) | Board sets % | Fixed 30% | | Fee basis | Tiered % by years | Fixed (form+sub) | Board sets % | Fixed 30% |
| Board approval | Yes | No | Yes (sets fee) | Yes | | Board approval | Yes | No | Yes (sets fee) | Yes |
| Form fill step | No | Yes (wife fills) | No | No | | Form fill step | No | Yes (wife fills) | No | No |
......
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