Commit 20850756 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(waiver): comprehensive debt check, board sees all dependents, detailed receipts

1. Debt check now covers ALL financial types: subscriptions, fines, installments,
   payment requests, unpaid sales, and seasonal memberships — for member + all dependents.
2. Board approval screen shows ALL target dependents (not just excess) with full details
   (name, age, DOB, category, within-allowance flag). Board decides per-person fees independently.
3. Receipt/payment request includes per-individual itemized breakdown with calculation
   method, person type, age category — stored as structured JSON for audit trail.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 99e7632a
......@@ -161,27 +161,27 @@ class WaiverController extends Controller
$sourceDebtCheck = WaiverProcessor::checkDebtsComprehensive((int) $waiver['source_member_id']);
$targetDebtCheck = $waiver['target_member_id'] ? WaiverProcessor::checkDebtsComprehensive((int) $waiver['target_member_id']) : null;
// Per-individual excess persons for board fee assignment
$excessIndividuals = [];
if ($waiver['target_member_id'] && $comparison && $comparison['has_excess']) {
$excessIndividuals = WaiverProcessor::getExcessIndividuals((int) $waiver['target_member_id'], $originalDeps);
// ALL target dependents for board fee assignment (board sees everyone, decides per-person)
$allTargetDependents = [];
if ($waiver['target_member_id']) {
$allTargetDependents = WaiverProcessor::getAllTargetDependents((int) $waiver['target_member_id'], $originalDeps);
}
// Already-saved individual fees
$individualFees = WaiverProcessor::getIndividualFees((int) $id);
return $this->view('Waiver.Views.show', [
'waiver' => $waiver,
'source_deps' => $sourceDeps,
'target_deps' => $targetDeps,
'source_details' => $sourceDetails,
'target_details' => $targetDetails,
'original_deps' => $originalDeps,
'comparison' => $comparison,
'source_debt_check' => $sourceDebtCheck,
'target_debt_check' => $targetDebtCheck,
'excess_individuals' => $excessIndividuals,
'individual_fees' => $individualFees,
'waiver' => $waiver,
'source_deps' => $sourceDeps,
'target_deps' => $targetDeps,
'source_details' => $sourceDetails,
'target_details' => $targetDetails,
'original_deps' => $originalDeps,
'comparison' => $comparison,
'source_debt_check' => $sourceDebtCheck,
'target_debt_check' => $targetDebtCheck,
'all_target_dependents' => $allTargetDependents,
'individual_fees' => $individualFees,
]);
}
......@@ -336,40 +336,112 @@ class WaiverController extends Controller
return $this->redirect("/waivers/{$id}")->withError('لا توجد رسوم مطلوبة');
}
// Get target member name for the receipt
// Get member names for the receipt
$targetMember = $db->selectOne("SELECT full_name_ar FROM members WHERE id = ?", [(int) $waiver['target_member_id']]);
$targetName = $targetMember['full_name_ar'] ?? '';
$sourceMember = $db->selectOne("SELECT full_name_ar FROM members WHERE id = ?", [(int) $waiver['source_member_id']]);
$sourceName = $sourceMember['full_name_ar'] ?? '';
// Load per-individual fees for detailed receipt
$individualFees = WaiverProcessor::getIndividualFees((int) $id);
// Build detailed breakdown for receipt
// Build detailed breakdown for receipt (per-person)
$breakdown = [
'📋 تنازل عن العضوية رقم: ' . ($waiver['membership_number'] ?? ''),
'👤 المتنازل إليه (المشتري): ' . $targetName,
'💰 قيمة العضوية: ' . money($waiver['membership_value_at_waiver'] ?? '0'),
'تنازل عن العضوية رقم: ' . ($waiver['membership_number'] ?? ''),
'المتنازل إليه (المشتري): ' . $targetName,
'قيمة العضوية: ' . money($waiver['membership_value_at_waiver'] ?? '0'),
'═══════════════════════════',
'💵 رسوم التنازل: ' . money($waiverFee) . ' (' . ($waiver['waiver_fee_percentage'] ?? '30') . '%)',
'رسوم التنازل الأساسية: ' . money($waiverFee) . ' (' . ($waiver['waiver_fee_percentage'] ?? '30') . '% من قيمة العضوية)',
];
if (bccomp($spouseFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['spouse_fee_type'] ?? '') === 'percentage'
? ($waiver['spouse_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['spouse_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👩 رسوم زوجات إضافيات: ' . ($waiver['excess_spouses_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($spouseFeeTotal);
}
if (bccomp($childFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['child_fee_type'] ?? '') === 'percentage'
? ($waiver['child_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['child_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👶 رسوم أبناء إضافيين: ' . ($waiver['excess_children_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($childFeeTotal);
}
if (bccomp($tempFeeTotal, '0', 2) > 0) {
$rateLabel = ($waiver['temporary_fee_type'] ?? '') === 'percentage'
? ($waiver['temporary_fee_rate'] ?? '0') . '% من قيمة العضوية'
: money($waiver['temporary_fee_rate'] ?? '0') . ' ثابت';
$breakdown[] = '👤 رسوم أعضاء مؤقتين: ' . ($waiver['excess_temporary_count'] ?? 0) . ' × ' . $rateLabel . ' = ' . money($tempFeeTotal);
// Per-individual detailed items
$receiptItems = [];
$receiptItems[] = [
'description' => 'رسوم التنازل (' . ($waiver['waiver_fee_percentage'] ?? '30') . '% من قيمة العضوية)',
'person_name' => $targetName,
'person_type' => 'المتنازل إليه',
'amount' => $waiverFee,
'calculation' => ($waiver['waiver_fee_percentage'] ?? '30') . '% × ' . money($waiver['membership_value_at_waiver'] ?? '0'),
];
if (!empty($individualFees)) {
$spouseItems = array_filter($individualFees, fn($f) => $f['person_type'] === 'spouse');
$childItems = array_filter($individualFees, fn($f) => $f['person_type'] === 'child');
$tempItems = array_filter($individualFees, fn($f) => $f['person_type'] === 'temporary');
if (!empty($spouseItems)) {
$breakdown[] = '--- رسوم الزوجات ---';
foreach ($spouseItems as $fee) {
if (bccomp($fee['fee_amount'] ?? '0', '0', 2) <= 0) continue;
$calcMethod = ($fee['fee_type'] ?? '') === 'percentage'
? $fee['fee_rate'] . '% من قيمة العضوية'
: money($fee['fee_rate'] ?? '0') . ' مبلغ ثابت';
$breakdown[] = 'الزوجة: ' . ($fee['person_name'] ?? '') . ' — ' . $calcMethod . ' = ' . money($fee['fee_amount']);
$receiptItems[] = [
'description' => 'رسوم زوجة — ' . ($fee['person_name'] ?? ''),
'person_name' => $fee['person_name'] ?? '',
'person_type' => 'زوجة',
'amount' => $fee['fee_amount'],
'calculation' => $calcMethod,
];
}
}
if (!empty($childItems)) {
$breakdown[] = '--- رسوم الأبناء ---';
foreach ($childItems as $fee) {
if (bccomp($fee['fee_amount'] ?? '0', '0', 2) <= 0) continue;
$calcMethod = ($fee['fee_type'] ?? '') === 'percentage'
? $fee['fee_rate'] . '% من قيمة العضوية'
: money($fee['fee_rate'] ?? '0') . ' مبلغ ثابت';
$ageInfo = $fee['age_years'] ? ' (' . (int) $fee['age_years'] . ' سنة' . (!empty($fee['age_category']) ? ' — ' . $fee['age_category'] : '') . ')' : '';
$breakdown[] = 'ابن/ة: ' . ($fee['person_name'] ?? '') . $ageInfo . ' — ' . $calcMethod . ' = ' . money($fee['fee_amount']);
$receiptItems[] = [
'description' => 'رسوم ابن/ة — ' . ($fee['person_name'] ?? '') . $ageInfo,
'person_name' => $fee['person_name'] ?? '',
'person_type' => 'ابن/ة',
'age_years' => $fee['age_years'] ?? null,
'age_category' => $fee['age_category'] ?? null,
'amount' => $fee['fee_amount'],
'calculation' => $calcMethod,
];
}
}
if (!empty($tempItems)) {
$breakdown[] = '--- رسوم الأعضاء المؤقتين ---';
foreach ($tempItems as $fee) {
if (bccomp($fee['fee_amount'] ?? '0', '0', 2) <= 0) continue;
$calcMethod = ($fee['fee_type'] ?? '') === 'percentage'
? $fee['fee_rate'] . '% من قيمة العضوية'
: money($fee['fee_rate'] ?? '0') . ' مبلغ ثابت';
$breakdown[] = 'عضو مؤقت: ' . ($fee['person_name'] ?? '') . ' — ' . $calcMethod . ' = ' . money($fee['fee_amount']);
$receiptItems[] = [
'description' => 'رسوم عضو مؤقت — ' . ($fee['person_name'] ?? ''),
'person_name' => $fee['person_name'] ?? '',
'person_type' => 'عضو مؤقت',
'amount' => $fee['fee_amount'],
'calculation' => $calcMethod,
];
}
}
} elseif (bccomp($excessTotal, '0', 2) > 0) {
$breakdown[] = 'رسوم تابعين إضافيين: ' . money($excessTotal);
$receiptItems[] = [
'description' => 'رسوم تابعين إضافيين',
'person_name' => '',
'person_type' => 'تابعين',
'amount' => $excessTotal,
'calculation' => 'حسب قرار مجلس الأمناء',
];
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي: ' . money($totalFee);
$breakdown[] = 'إجمالي رسوم التنازل: ' . money($waiverFee);
if (bccomp($spouseFeeTotal, '0', 2) > 0) $breakdown[] = 'إجمالي رسوم الزوجات: ' . money($spouseFeeTotal);
if (bccomp($childFeeTotal, '0', 2) > 0) $breakdown[] = 'إجمالي رسوم الأبناء: ' . money($childFeeTotal);
if (bccomp($tempFeeTotal, '0', 2) > 0) $breakdown[] = 'إجمالي رسوم المؤقتين: ' . money($tempFeeTotal);
$breakdown[] = 'الإجمالي النهائي: ' . money($totalFee);
// Payment request in TARGET member's name (buyer pays)
$result = PaymentRequestService::createRequest([
......@@ -379,7 +451,22 @@ class WaiverController extends Controller
'related_entity_type' => 'waiver_requests',
'related_entity_id' => (int) $id,
'description_ar' => 'رسوم تنازل عن عضوية #' . ($waiver['membership_number'] ?? '') . ' — ' . $targetName,
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
'notes' => json_encode([
'fee_breakdown' => $breakdown,
'receipt_items' => $receiptItems,
'totals' => [
'waiver_fee' => $waiverFee,
'spouse_fee_total' => $spouseFeeTotal,
'child_fee_total' => $childFeeTotal,
'temporary_fee_total' => $tempFeeTotal,
'excess_total' => $excessTotal,
'grand_total' => $totalFee,
],
'membership_number' => $waiver['membership_number'] ?? '',
'membership_value' => $waiver['membership_value_at_waiver'] ?? '0',
'source_member_name' => $sourceName,
'target_member_name' => $targetName,
], JSON_UNESCAPED_UNICODE),
]);
if (!$result['success']) {
......@@ -438,13 +525,15 @@ class WaiverController extends Controller
$comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps);
if ($comparison['has_excess']) {
$individualFees = WaiverProcessor::getIndividualFees((int) $id);
$totalApprovedExcessFee = bcadd(
bcadd($waiver['spouse_fee_total'] ?? '0', $waiver['child_fee_total'] ?? '0', 2),
$waiver['temporary_fee_total'] ?? '0', 2
);
$legacyExcessFee = $waiver['excess_fee_amount'] ?? '0';
$boardReviewed = !empty($individualFees) || bccomp($totalApprovedExcessFee, '0', 2) > 0 || bccomp((string) $legacyExcessFee, '0', 2) > 0;
if (bccomp($totalApprovedExcessFee, '0', 2) <= 0 && bccomp((string) $legacyExcessFee, '0', 2) <= 0) {
if (!$boardReviewed) {
$parts = [];
if ($comparison['excess_spouses'] > 0) $parts[] = $comparison['excess_spouses'] . ' زوجة زائدة';
if ($comparison['excess_children'] > 0) $parts[] = $comparison['excess_children'] . ' ابن زائد';
......
......@@ -117,12 +117,15 @@ final class WaiverProcessor
$comparison = self::compareDependents($originalDeps, $targetDeps);
if ($comparison['has_excess']) {
// Board must have reviewed: either individual fees saved OR legacy totals set
$individualFees = self::getIndividualFees($waiverId);
$totalApprovedExcessFee = bcadd(
bcadd($waiver['spouse_fee_total'] ?? '0', $waiver['child_fee_total'] ?? '0', 2),
$waiver['temporary_fee_total'] ?? '0', 2
);
$legacyExcessFee = $waiver['excess_fee_amount'] ?? '0';
if (bccomp($totalApprovedExcessFee, '0', 2) <= 0 && bccomp((string) $legacyExcessFee, '0', 2) <= 0) {
$boardReviewed = !empty($individualFees) || bccomp($totalApprovedExcessFee, '0', 2) > 0 || bccomp((string) $legacyExcessFee, '0', 2) > 0;
if (!$boardReviewed) {
return ['success' => false, 'reason' => 'excess_fees_not_set'];
}
}
......@@ -150,8 +153,9 @@ final class WaiverProcessor
}
/**
* Comprehensive debt check: member + all dependents (spouses, children, temporary).
* Returns detailed breakdown per person.
* Comprehensive debt check: member + ALL dependents (spouses, children, temporary).
* Checks ALL financial obligation types without exception.
* Returns detailed breakdown per person with navigation links.
*/
public static function checkDebtsComprehensive(int $memberId): array
{
......@@ -162,30 +166,37 @@ final class WaiverProcessor
$memberName = $member['full_name_ar'] ?? 'عضو #' . $memberId;
$details = [];
// 1. Check subscriptions for this member (including person_type entries for dependents)
// Build map of all dependents for labeling
$dependentMap = self::buildDependentMap($memberId);
// 1. Subscriptions (covers member + all dependents via person_type/person_id)
$subs = $db->select(
"SELECT id, person_type, person_id, person_name, financial_year, (total_amount - paid_amount + fine_amount) as due
FROM subscriptions WHERE member_id = ? AND status IN ('pending','overdue') AND (total_amount - paid_amount + fine_amount) > 0",
[$memberId]
);
foreach ($subs as $sub) {
$personLabel = self::getPersonLabel($sub['person_type'] ?? 'member', $sub['person_name'] ?? $memberName);
$personType = $sub['person_type'] ?? 'member';
$personName = $sub['person_name'] ?? $memberName;
$personLabel = self::getPersonLabel($personType, $personName);
$details[] = [
'member_id' => $memberId,
'member_name' => $memberName,
'person_name' => $sub['person_name'] ?? $memberName,
'person_type' => $sub['person_type'] ?? 'member',
'person_name' => $personName,
'person_type' => $personType,
'person_label' => $personLabel,
'debt_type' => 'اشتراك سنوي',
'debt_type_code' => 'subscription',
'period' => $sub['financial_year'] ?? '',
'amount' => $sub['due'],
'description' => 'اشتراك سنوي ' . ($sub['financial_year'] ?? '') . ' — ' . $personLabel,
'payment_url' => '/payments/process/' . $memberId,
];
}
// 2. Fines on main member
// 2. Fines (member-level — system stores all fines under member_id)
$fines = $db->select(
"SELECT id, (amount - paid_amount) as due, created_at FROM fines WHERE member_id = ? AND status IN ('imposed','pending','appeal_upheld') AND (amount - paid_amount) > 0",
"SELECT id, reason, (amount - paid_amount) as due, created_at FROM fines WHERE member_id = ? AND status IN ('imposed','pending','appeal_upheld') AND (amount - paid_amount) > 0",
[$memberId]
);
foreach ($fines as $fine) {
......@@ -194,17 +205,19 @@ final class WaiverProcessor
'member_name' => $memberName,
'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'person_label' => 'العضو الأساسي (' . $memberName . ')',
'debt_type' => 'غرامة',
'debt_type_code' => 'fine',
'period' => substr($fine['created_at'] ?? '', 0, 10),
'amount' => $fine['due'],
'description' => 'غرامة — العضو الأساسي',
'description' => 'غرامة' . (!empty($fine['reason']) ? ' — ' . $fine['reason'] : '') . ' — العضو الأساسي',
'payment_url' => '/payments/process/' . $memberId,
];
}
// 3. Installments on main member
// 3. Installments (active/overdue installment plans)
$installments = $db->select(
"SELECT s.id, s.due_date, (s.amount - s.paid_amount) as due
"SELECT s.id, s.due_date, (s.amount - s.paid_amount) as due, p.description as plan_desc
FROM installment_schedule s JOIN installment_plans p ON p.id = s.installment_plan_id
WHERE p.member_id = ? AND p.status IN ('active','overdue') AND s.status IN ('pending','overdue') AND (s.amount - s.paid_amount) > 0",
[$memberId]
......@@ -215,31 +228,82 @@ final class WaiverProcessor
'member_name' => $memberName,
'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'debt_type' => 'قسط عضوية',
'person_label' => 'العضو الأساسي (' . $memberName . ')',
'debt_type' => 'قسط',
'debt_type_code' => 'installment',
'period' => $inst['due_date'] ?? '',
'amount' => $inst['due'],
'description' => 'قسط عضوية مستحق ' . ($inst['due_date'] ?? '') . ' — العضو الأساسي',
'description' => 'قسط مستحق ' . ($inst['due_date'] ?? '') . (!empty($inst['plan_desc']) ? ' — ' . $inst['plan_desc'] : ''),
'payment_url' => '/payments/process/' . $memberId,
];
}
// 4. Pending payment requests
// 4. Pending payment requests (includes service fees, admin fees, transfer fees, etc.)
$requests = $db->select(
"SELECT id, payment_type, amount, description_ar FROM payment_requests
WHERE member_id = ? AND status = 'pending' AND is_voided = 0",
[$memberId]
);
foreach ($requests as $req) {
$typeLabel = self::getPaymentTypeLabel($req['payment_type'] ?? '');
$details[] = [
'member_id' => $memberId,
'member_name' => $memberName,
'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي',
'debt_type' => 'طلب دفع معلق',
'person_label' => 'العضو الأساسي (' . $memberName . ')',
'debt_type' => $typeLabel,
'debt_type_code' => 'payment_request',
'period' => '',
'amount' => $req['amount'],
'description' => ($req['description_ar'] ?? 'طلب دفع') . ' — العضو الأساسي',
'description' => ($req['description_ar'] ?? $typeLabel),
'payment_url' => '/payments/process/' . $memberId,
];
}
// 5. Unpaid sales (credit sales at POS/restaurant/etc.)
$sales = $db->select(
"SELECT id, invoice_number, (total_amount - amount_paid) as due, created_at
FROM sales WHERE member_id = ? AND status = 'completed' AND (total_amount - amount_paid) > 0",
[$memberId]
);
foreach ($sales as $sale) {
$details[] = [
'member_id' => $memberId,
'member_name' => $memberName,
'person_name' => $memberName,
'person_type' => 'member',
'person_label' => 'العضو الأساسي (' . $memberName . ')',
'debt_type' => 'فاتورة مبيعات',
'debt_type_code' => 'sale',
'period' => substr($sale['created_at'] ?? '', 0, 10),
'amount' => $sale['due'],
'description' => 'فاتورة #' . ($sale['invoice_number'] ?? $sale['id']),
'payment_url' => '/payments/process/' . $memberId,
];
}
// 6. Seasonal memberships with outstanding fees (if fee tracking exists)
$seasonals = $db->select(
"SELECT id, person_type, person_name, fee_amount, status
FROM seasonal_memberships WHERE member_id = ? AND status IN ('pending','overdue') AND fee_amount > 0",
[$memberId]
);
foreach ($seasonals as $sm) {
$pType = $sm['person_type'] ?? 'member';
$pName = $sm['person_name'] ?? $memberName;
$details[] = [
'member_id' => $memberId,
'member_name' => $memberName,
'person_name' => $pName,
'person_type' => $pType,
'person_label' => self::getPersonLabel($pType, $pName),
'debt_type' => 'رسوم عضوية موسمية',
'debt_type_code' => 'seasonal',
'period' => '',
'amount' => $sm['fee_amount'],
'description' => 'عضوية موسمية — ' . self::getPersonLabel($pType, $pName),
'payment_url' => '/payments/process/' . $memberId,
];
}
......@@ -253,9 +317,66 @@ final class WaiverProcessor
'debts' => $details,
'total' => $totalDebt,
'member_name' => $memberName,
'dependents_checked' => $dependentMap,
];
}
/**
* Build a map of all dependents for a member (used to verify completeness).
*/
private static function buildDependentMap(int $memberId): array
{
$db = App::getInstance()->db();
$map = ['spouses' => [], 'children' => [], 'temporary' => []];
$spouses = $db->select(
"SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0",
[$memberId]
);
foreach ($spouses as $s) {
$map['spouses'][] = ['id' => (int) $s['id'], 'name' => $s['full_name_ar'] ?? ''];
}
$children = $db->select(
"SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0",
[$memberId]
);
foreach ($children as $c) {
$map['children'][] = ['id' => (int) $c['id'], 'name' => $c['full_name_ar'] ?? ''];
}
$temps = $db->select(
"SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0",
[$memberId]
);
foreach ($temps as $t) {
$map['temporary'][] = ['id' => (int) $t['id'], 'name' => $t['full_name_ar'] ?? ''];
}
return $map;
}
private static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
'subscription' => 'اشتراك سنوي',
'fine' => 'غرامة',
'installment' => 'قسط',
'form_fee' => 'رسوم استمارة',
'membership_fee' => 'رسوم عضوية',
'down_payment' => 'مقدم عضوية',
'waiver_fee' => 'رسوم تنازل',
'transfer_fee' => 'رسوم تحويل',
'separation_fee' => 'رسوم فصل',
'service_fee' => 'رسوم خدمة',
'admin_fee' => 'رسوم إدارية',
'renewal_fee' => 'رسوم تجديد',
'carnet_fee' => 'رسوم كارنيه',
'guest_fee' => 'رسوم ضيافة',
default => 'طلب دفع معلق',
};
}
/**
* Simple debt check (for backward compat and quick validation).
*/
......@@ -433,9 +554,107 @@ final class WaiverProcessor
return bcmul($perUnit, (string) $excessCount, 2);
}
/**
* Get ALL dependents of the target member for board fee assignment.
* Returns every spouse, child, and temporary member with full details.
* The board decides independently which individuals to charge and how much.
*/
public static function getAllTargetDependents(int $targetMemberId, array $originalCounts): array
{
$db = App::getInstance()->db();
$today = new \DateTime();
$all = [];
$spouses = $db->select(
"SELECT id, full_name_ar, national_id, status FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY id",
[$targetMemberId]
);
$allowedSpouses = $originalCounts['spouses'] ?? 0;
$order = 1;
foreach ($spouses as $idx => $s) {
$all[] = [
'person_type' => 'spouse',
'person_id' => (int) $s['id'],
'person_name' => $s['full_name_ar'] ?? '',
'person_order' => $order++,
'national_id' => $s['national_id'] ?? '',
'status' => $s['status'] ?? 'active',
'is_within_allowance' => ($idx < $allowedSpouses),
'age_years' => null,
'age_category' => null,
'age_category_code' => null,
'date_of_birth' => null,
'relationship' => null,
];
}
$children = $db->select(
"SELECT id, full_name_ar, national_id, date_of_birth, gender, relationship, classification, status
FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY date_of_birth",
[$targetMemberId]
);
$allowedChildren = $originalCounts['children'] ?? 0;
$order = 1;
foreach ($children as $idx => $c) {
$dob = !empty($c['date_of_birth']) ? new \DateTime($c['date_of_birth']) : null;
$ageYears = $dob ? $dob->diff($today)->y : null;
$ageCat = null;
$ageCatCode = null;
if ($ageYears !== null) {
if ($ageYears < 12) { $ageCat = 'أقل من 12 سنة'; $ageCatCode = 'under_12'; }
elseif ($ageYears < 16) { $ageCat = 'من 12 إلى أقل من 16 سنة'; $ageCatCode = '12_to_16'; }
elseif ($ageYears < 18) { $ageCat = 'من 16 إلى أقل من 18 سنة'; $ageCatCode = '16_to_18'; }
elseif ($ageYears < 25) { $ageCat = 'من 18 إلى أقل من 25 سنة'; $ageCatCode = '18_to_25'; }
else { $ageCat = '25 سنة فأكثر'; $ageCatCode = '25_plus'; }
}
$all[] = [
'person_type' => 'child',
'person_id' => (int) $c['id'],
'person_name' => $c['full_name_ar'] ?? '',
'person_order' => $order++,
'national_id' => $c['national_id'] ?? '',
'status' => $c['status'] ?? 'active',
'is_within_allowance' => ($idx < $allowedChildren),
'age_years' => $ageYears,
'age_category' => $ageCat,
'age_category_code' => $ageCatCode,
'date_of_birth' => $c['date_of_birth'] ?? null,
'relationship' => $c['relationship'] ?? null,
'gender' => $c['gender'] ?? null,
'classification' => $c['classification'] ?? null,
];
}
$temps = $db->select(
"SELECT id, full_name_ar, national_id, status FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id",
[$targetMemberId]
);
$allowedTemps = $originalCounts['temporary'] ?? 0;
$order = 1;
foreach ($temps as $idx => $t) {
$all[] = [
'person_type' => 'temporary',
'person_id' => (int) $t['id'],
'person_name' => $t['full_name_ar'] ?? '',
'person_order' => $order++,
'national_id' => $t['national_id'] ?? '',
'status' => $t['status'] ?? 'active',
'is_within_allowance' => ($idx < $allowedTemps),
'age_years' => null,
'age_category' => null,
'age_category_code' => null,
'date_of_birth' => null,
'relationship' => null,
];
}
return $all;
}
/**
* Identify excess dependents (individual persons) for per-person fee assignment.
* Compares target dependents vs original counts — returns the EXTRA individuals.
* @deprecated Use getAllTargetDependents() instead — board should see all and decide.
*/
public static function getExcessIndividuals(int $targetMemberId, array $originalCounts): array
{
......
......@@ -422,7 +422,7 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
</div>
<?php endif; ?>
<!-- SECTION 7: Board Approval (per-individual fee assignment) -->
<!-- SECTION 7: Board Approval (per-individual fee assignment — shows ALL dependents) -->
<?php if ($waiver['status'] === 'requested' && can('waiver.approve')): ?>
<div class="card" style="padding:20px;margin-bottom:20px;background:#EFF6FF;border:2px solid #3B82F6;">
<h4 style="margin:0 0 15px;color:#1D4ED8;">🏛 اعتماد مجلس الأمناء</h4>
......@@ -434,34 +434,41 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<input type="text" name="board_decision_reference" class="form-input" placeholder="مثال: قرار جلسة 2026/06/15">
</div>
<?php if (!empty($excess_individuals)): ?>
<div style="padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;margin-bottom:15px;">
<strong style="color:#DC2626;">⚠ يوجد تابعين زائدين — يجب تحديد الرسوم لكل فرد على حدة:</strong>
<p style="font-size:12px;color:#7F1D1D;margin:5px 0 0;">قيمة العضوية الحالية: <strong><?= money($waiver['membership_value_at_waiver'] ?? '0') ?></strong> — يمكن تحديد مبلغ ثابت أو نسبة من قيمة العضوية لكل شخص بشكل مستقل</p>
<?php if (!empty($all_target_dependents)): ?>
<div style="padding:15px;background:#EFF6FF;border:1px solid #BFDBFE;border-radius:8px;margin-bottom:15px;">
<strong style="color:#1D4ED8;">👥 جميع تابعي المتنازل إليه — يحدد مجلس الأمناء الرسوم لكل فرد بشكل مستقل:</strong>
<p style="font-size:12px;color:#1E40AF;margin:5px 0 0;">قيمة العضوية الحالية: <strong><?= money($waiver['membership_value_at_waiver'] ?? '0') ?></strong> — لكل شخص: اترك "بدون رسوم" أو حدد مبلغ ثابت أو نسبة من قيمة العضوية</p>
<p style="font-size:11px;color:#6B7280;margin:4px 0 0;">الأشخاص المُعلّمين "ضمن العدد الأصلي" يمكن عدم فرض رسوم عليهم، لكن القرار النهائي لمجلس الأمناء.</p>
</div>
<?php
$spouseExcess = array_filter($excess_individuals, fn($p) => $p['person_type'] === 'spouse');
$childExcess = array_filter($excess_individuals, fn($p) => $p['person_type'] === 'child');
$tempExcess = array_filter($excess_individuals, fn($p) => $p['person_type'] === 'temporary');
$allSpouses = array_filter($all_target_dependents, fn($p) => $p['person_type'] === 'spouse');
$allChildren = array_filter($all_target_dependents, fn($p) => $p['person_type'] === 'child');
$allTemps = array_filter($all_target_dependents, fn($p) => $p['person_type'] === 'temporary');
$indIdx = 0;
?>
<!-- Spouse individuals -->
<?php if (!empty($spouseExcess)): ?>
<!-- All Spouse individuals -->
<?php if (!empty($allSpouses)): ?>
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:#FDF2F8;border-radius:6px;">
<span style="font-size:18px;">👩</span>
<strong style="color:#9D174D;">الزوجات الإضافيات (<?= count($spouseExcess) ?>)</strong>
<strong style="color:#9D174D;">الزوجات (<?= count($allSpouses) ?>)</strong>
<span style="font-size:11px;color:#6B7280;margin-right:auto;">العدد الأصلي: <?= (int) ($original_deps['spouses'] ?? 0) ?></span>
</div>
<?php foreach ($spouseExcess as $person): ?>
<div style="padding:14px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:10px;" class="ind-fee-card" data-idx="<?= $indIdx ?>">
<?php foreach ($allSpouses as $person): ?>
<?php $isExcess = !($person['is_within_allowance'] ?? false); ?>
<div style="padding:14px;background:<?= $isExcess ? '#FEF2F2' : '#F9FAFB' ?>;border:1px solid <?= $isExcess ? '#FECACA' : '#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;">
<strong style="color:#374151;font-size:14px;"><?= e($person['person_name']) ?></strong>
<?php if (!empty($person['national_id'])): ?>
<span style="font-size:11px;color:#6B7280;">رقم قومي: <?= e($person['national_id']) ?></span>
<?php endif; ?>
<span style="background:#F0FDF4;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;"><?= e($person['status'] ?? 'active') ?></span>
<?php if ($isExcess): ?>
<span style="background:#FEE2E2;color:#DC2626;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;">زائدة عن العدد الأصلي</span>
<?php else: ?>
<span style="background:#ECFDF5;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;">ضمن العدد الأصلي</span>
<?php endif; ?>
</div>
<input type="hidden" name="ind_person_type[]" value="spouse">
<input type="hidden" name="ind_person_id[]" value="<?= (int) $person['person_id'] ?>">
......@@ -475,15 +482,15 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<div style="display:grid;grid-template-columns:1fr 1fr 120px;gap:10px;align-items:end;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">طريقة الاحتساب</label>
<select name="ind_fee_type[]" class="form-select" required onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">— اختر —</option>
<select name="ind_fee_type[]" class="form-select" onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">بدون رسوم</option>
<option value="fixed">مبلغ ثابت</option>
<option value="percentage">نسبة من قيمة العضوية</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">القيمة</label>
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" required oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" value="0" oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
</div>
<div style="text-align:center;padding:8px 0;">
<span style="font-size:12px;color:#6B7280;">= </span>
......@@ -496,21 +503,24 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
</div>
<?php endif; ?>
<!-- Child individuals -->
<?php if (!empty($childExcess)): ?>
<!-- All Child individuals -->
<?php if (!empty($allChildren)): ?>
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:#EFF6FF;border-radius:6px;">
<span style="font-size:18px;">👶</span>
<strong style="color:#1E40AF;">الأبناء الإضافيون (<?= count($childExcess) ?>)</strong>
<strong style="color:#1E40AF;">الأبناء (<?= count($allChildren) ?>)</strong>
<span style="font-size:11px;color:#6B7280;margin-right:auto;">العدد الأصلي: <?= (int) ($original_deps['children'] ?? 0) ?></span>
</div>
<?php foreach ($childExcess as $person): ?>
<?php foreach ($allChildren as $person): ?>
<?php
$isExcess = !($person['is_within_allowance'] ?? false);
$catColor = match($person['age_category_code'] ?? '') {
'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';
$is18Plus = in_array($person['age_category_code'] ?? '', ['18_to_25', '25_plus']);
?>
<div style="padding:14px;background:<?= $is25Plus ? '#FEF2F2' : '#fff' ?>;border:1px solid <?= $is25Plus ? '#FECACA' : '#E5E7EB' ?>;border-radius:8px;margin-bottom:10px;" class="ind-fee-card" data-idx="<?= $indIdx ?>">
<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="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>
<?php if ($person['date_of_birth']): ?>
......@@ -520,15 +530,24 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<span style="font-size:12px;font-weight:600;color:#374151;">(<?= (int) $person['age_years'] ?> سنة)</span>
<?php endif; ?>
<span style="background:<?= $catColor ?>15;color:<?= $catColor ?>;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;"><?= e($person['age_category'] ?? '') ?></span>
<?php if ($person['relationship']): ?>
<span style="font-size:11px;color:#6B7280;"><?= $person['relationship'] === 'son' ? 'ابن' : 'ابنة' ?></span>
<?php if ($person['relationship'] ?? null): ?>
<span style="font-size:11px;color:#6B7280;"><?= ($person['relationship'] ?? '') === 'son' ? 'ابن' : 'ابنة' ?></span>
<?php endif; ?>
<?php if ($isExcess): ?>
<span style="background:#FEE2E2;color:#DC2626;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;">زائد عن العدد الأصلي</span>
<?php else: ?>
<span style="background:#ECFDF5;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;">ضمن العدد الأصلي</span>
<?php endif; ?>
</div>
<?php if ($is25Plus): ?>
<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;">⚠ تجاوز 25 سنة — قد يتطلب فصل العضوية</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>
<?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;">
⚠ بلغ السن القانوني (18+ سنة) — يُراعى عند تحديد الرسوم
</div>
<?php endif; ?>
<input type="hidden" name="ind_person_type[]" value="child">
<input type="hidden" name="ind_person_id[]" value="<?= (int) $person['person_id'] ?>">
......@@ -542,15 +561,15 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<div style="display:grid;grid-template-columns:1fr 1fr 120px;gap:10px;align-items:end;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">طريقة الاحتساب</label>
<select name="ind_fee_type[]" class="form-select" required onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">— اختر —</option>
<select name="ind_fee_type[]" class="form-select" onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">بدون رسوم</option>
<option value="fixed">مبلغ ثابت</option>
<option value="percentage">نسبة من قيمة العضوية</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">القيمة</label>
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" required oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" value="0" oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
</div>
<div style="text-align:center;padding:8px 0;">
<span style="font-size:12px;color:#6B7280;">= </span>
......@@ -563,21 +582,27 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
</div>
<?php endif; ?>
<!-- Temporary individuals -->
<?php if (!empty($tempExcess)): ?>
<!-- All Temporary individuals -->
<?php if (!empty($allTemps)): ?>
<div style="margin-bottom:20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:8px 12px;background:#F0FDF4;border-radius:6px;">
<span style="font-size:18px;">👤</span>
<strong style="color:#166534;">الأعضاء المؤقتون الإضافيون (<?= count($tempExcess) ?>)</strong>
<strong style="color:#166534;">الأعضاء المؤقتون (<?= count($allTemps) ?>)</strong>
<span style="font-size:11px;color:#6B7280;margin-right:auto;">العدد الأصلي: <?= (int) ($original_deps['temporary'] ?? 0) ?></span>
</div>
<?php foreach ($tempExcess as $person): ?>
<div style="padding:14px;background:#fff;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:10px;" class="ind-fee-card" data-idx="<?= $indIdx ?>">
<?php foreach ($allTemps as $person): ?>
<?php $isExcess = !($person['is_within_allowance'] ?? false); ?>
<div style="padding:14px;background:<?= $isExcess ? '#FEF2F2' : '#F9FAFB' ?>;border:1px solid <?= $isExcess ? '#FECACA' : '#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;">
<strong style="color:#374151;font-size:14px;"><?= e($person['person_name']) ?></strong>
<?php if (!empty($person['national_id'])): ?>
<span style="font-size:11px;color:#6B7280;">رقم قومي: <?= e($person['national_id']) ?></span>
<?php endif; ?>
<span style="background:#F0FDF4;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;"><?= e($person['status'] ?? 'active') ?></span>
<?php if ($isExcess): ?>
<span style="background:#FEE2E2;color:#DC2626;padding:2px 6px;border-radius:3px;font-size:11px;font-weight:600;">زائد عن العدد الأصلي</span>
<?php else: ?>
<span style="background:#ECFDF5;color:#059669;padding:2px 6px;border-radius:3px;font-size:11px;">ضمن العدد الأصلي</span>
<?php endif; ?>
</div>
<input type="hidden" name="ind_person_type[]" value="temporary">
<input type="hidden" name="ind_person_id[]" value="<?= (int) $person['person_id'] ?>">
......@@ -591,15 +616,15 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<div style="display:grid;grid-template-columns:1fr 1fr 120px;gap:10px;align-items:end;">
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">طريقة الاحتساب</label>
<select name="ind_fee_type[]" class="form-select" required onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">— اختر —</option>
<select name="ind_fee_type[]" class="form-select" onchange="calcIndFee(<?= $indIdx ?>)">
<option value="">بدون رسوم</option>
<option value="fixed">مبلغ ثابت</option>
<option value="percentage">نسبة من قيمة العضوية</option>
</select>
</div>
<div class="form-group" style="margin:0;">
<label class="form-label" style="font-size:12px;">القيمة</label>
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" required oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
<input type="number" name="ind_fee_rate[]" class="form-input" step="0.01" min="0" value="0" oninput="calcIndFee(<?= $indIdx ?>)" data-idx="<?= $indIdx ?>">
</div>
<div style="text-align:center;padding:8px 0;">
<span style="font-size:12px;color:#6B7280;">= </span>
......@@ -614,13 +639,13 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<!-- Live Total Summary -->
<div id="fee-summary" style="padding:15px;background:#F0FDF4;border:2px solid #BBF7D0;border-radius:8px;margin-bottom:15px;">
<strong style="color:#166534;display:block;margin-bottom:8px;">📊 ملخص الرسوم الإضافية:</strong>
<strong style="color:#166534;display:block;margin-bottom:8px;">📊 ملخص الرسوم:</strong>
<div id="fee-summary-content" style="font-size:13px;color:#166534;">
<div id="sum-spouses" style="display:none;margin-bottom:4px;"></div>
<div id="sum-children" style="display:none;margin-bottom:4px;"></div>
<div id="sum-temporary" style="display:none;margin-bottom:4px;"></div>
<hr style="margin:8px 0;border:none;border-top:1px dashed #BBF7D0;">
<div style="font-weight:700;">إجمالي الرسوم الإضافية: <span id="sum-excess-total">0.00</span> ج.م</div>
<div style="font-weight:700;">إجمالي رسوم التابعين: <span id="sum-excess-total">0.00</span> ج.م</div>
<div style="font-weight:700;">+ رسوم التنازل الأساسية: <?= money($waiver['waiver_fee_amount'] ?? '0') ?></div>
<div style="font-weight:700;font-size:16px;color:#0D7377;margin-top:4px;">= الإجمالي النهائي: <span id="sum-grand-total"><?= money($waiver['waiver_fee_amount'] ?? '0') ?></span></div>
</div>
......@@ -628,7 +653,7 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
<?php else: ?>
<div style="padding:12px;background:#F0FDF4;border:1px solid #BBF7D0;border-radius:8px;margin-bottom:15px;">
<span style="color:#059669;font-weight:600;">✅ لا يوجد تابعين زائدين — لا تُفرض رسوم إضافية</span>
<span style="color:#059669;font-weight:600;">✅ لا يوجد تابعين للمتنازل إليه — لا تُفرض رسوم إضافية</span>
</div>
<?php endif; ?>
......@@ -657,11 +682,11 @@ $statusIcon = match($waiver['status']) { 'requested' => '⏳', 'approved' => '
</div>
</div>
<?php if (!empty($excess_individuals)): ?>
<?php if (!empty($all_target_dependents)): ?>
<script>
var membershipValue = <?= (float) ($waiver['membership_value_at_waiver'] ?? 0) ?>;
var waiverBaseFee = <?= (float) ($waiver['waiver_fee_amount'] ?? 0) ?>;
var totalCards = <?= count($excess_individuals) ?>;
var totalCards = <?= count($all_target_dependents) ?>;
function calcIndFee(idx) {
var card = document.querySelector('.ind-fee-card[data-idx="' + idx + '"]');
......
# Waiver Module — Architecture Map
> **Last updated:** 2026-06-26 (Round 2: comprehensive debt check with per-person details, children age/DOB display, payment in target's name, send-to-cashier flow, status indicators)
> **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)
> **Status:** Living document — incrementally updated as new information is discovered
---
......@@ -120,71 +120,72 @@ requested → approved → fee_paid → completed
## 5. Core Business Flows
### 5.1 Waiver Request Flow (Bylaws-Compliant — 2026-06-22)
### 5.1 Waiver Request Flow (2026-07-02)
```
1. GET /waivers/create/{memberId}
- Validate member exists and is not archived
- Run WaiverProcessor::checkDebts(memberId) — checks subscriptions, fines, payment_requests
- If debts exist: show debt table, BLOCK form submission
- Run WaiverProcessor::checkDebtsComprehensive(memberId) — checks ALL financial types:
subscriptions, fines, installments, payment_requests, sales, seasonal_memberships
- Checks cover member + ALL dependents (via subscriptions.person_type)
- If debts exist: show detailed debt table per-person, BLOCK form submission
- Calculate waiver fee: WAIVER_FEE percentage × CURRENT membership value (from pricing_configs)
- Run WaiverProcessor::countDependents(memberId) — counts spouses, children, temporary
- Display: fee breakdown, dependent grid, debt status, document upload fields
2. POST /waivers/store/{memberId}
- VALIDATE: member exists, has membership_number
- VALIDATE: checkDebts → must be clear (blocks if any unpaid)
- VALIDATE: checkDebtsComprehensive(source) → must be clear (blocks if any unpaid)
- VALIDATE: checkDebtsComprehensive(target) → must be clear (blocks if any unpaid)
- Calculate fee: WAIVER_FEE (default 30%) × current pricing_configs value
- Count all active dependents (spouses + children + temp)
- Handle file uploads: waiver_request_doc, target_form_doc (PDF/JPG/PNG, max 5MB)
- Optionally accept target_member_id
- Create WaiverRequest (status='requested', debts_cleared=1)
- Dispatch: waiver.requested
- Send payment request to Cashier queue (waiver_fee amount)
3. POST /waivers/{id}/approve
- Must be status='requested'
- Board sets: board_decision_reference, excess_fee_percentage (if target has excess dependents)
- If target exists AND target dependents > original count:
excess_count = target_deps - original_count
excess_fee = (excess_fee_percentage × membership_value / 100) × excess_count
- Set approved_by, approved_at, excess_dependent_count, excess_fee_amount
- Board sees ALL target dependents (not just excess) via getAllTargetDependents()
- Each person shows: name, age, DOB, category, within-allowance indicator
- Board decides per-person: "بدون رسوم" / مبلغ ثابت / نسبة من قيمة العضوية
- Per-individual fees saved to waiver_individual_fees table
- Set approved_by, approved_at, per-category totals
- Status → 'approved'
- Sends combined payment request (waiver_fee + excess_fee) to Cashier
4. POST /waivers/{id}/reject
- Must be status='requested'
- Accepts rejection_reason in notes
- Status → 'rejected'
5. POST /waivers/{id}/pay (or via Cashier queue)
- Process payment: waiver_fee + excess_fee combined
5. POST /waivers/{id}/send-to-cashier
- Must be status='approved'
- Creates payment request with DETAILED receipt items:
waiver_fee + per-individual fees (names, ages, categories, calculation method)
- Receipt JSON includes: fee_breakdown, receipt_items[], totals{}
- Status stays 'approved' until payment is made
6. Payment via Cashier → waiver.fee_paid event → autoComplete()
- Status → 'fee_paid'
- Dispatch: waiver.fee_paid
- autoComplete validates: debts clear, board reviewed, target set
- If all conditions met → auto-executes transfer
6. POST /waivers/{id}/complete
- Optionally update target_member_id from POST data
- PRE-VALIDATION (before processor runs):
7. POST /waivers/{id}/complete (manual fallback)
- PRE-VALIDATION:
a. Target member must be set
b. checkDebts(source) — ALL financial obligations must be clear
c. countDependents(target) check:
- If target > original AND no excess_fee_amount set → block (board must approve first)
d. Status must be 'approved' or 'fee_paid'
e. Records new_dependent_count, debts_cleared=1
b. checkDebtsComprehensive(source) — ALL obligations clear
c. checkDebtsComprehensive(target) — ALL obligations clear
d. If excess dependents: board must have reviewed (individual_fees exist)
e. Status must be 'fee_paid'
f. Board approval required
- 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 (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', membership_type inherited)
- Record number chain via ArchiveService::recordNumberTransfer
a. Transaction:
- Archive snapshot of source member
- Archive source member (number=NULL, status='waived', is_archived=1)
- Transfer membership_number to target member
- 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)
b. Dispatch: waiver.completed
```
### 5.2 Fee Calculation (Per-Category — 2026-06-23)
......
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