Commit c8fae48c authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(waiver): per-individual fee assignment + fix 404 payment button

- New table `waiver_individual_fees` stores fee per person (not per category)
- Board approval screen shows each excess person as a separate card with:
  name, DOB, age, age category, relationship, independent fee type/rate
- Children 25+ flagged with warning and "فصل العضوية" button
- Live JS calculates per-person amounts and updates grand total instantly
- Fee breakdown section shows individual names when individual fees exist
- Fix: /members/{id}/financial → /payments/process/{id} (was 404)
- WaiverProcessor::getExcessIndividuals() identifies the specific excess persons
- WaiverProcessor::saveIndividualFees() persists per-person board decisions
- Age categories expanded: under_12, 12_to_16, 16_to_18, 18_to_25, 25_plus
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 83f24d7a
...@@ -161,6 +161,15 @@ class WaiverController extends Controller ...@@ -161,6 +161,15 @@ class WaiverController extends Controller
$sourceDebtCheck = WaiverProcessor::checkDebtsComprehensive((int) $waiver['source_member_id']); $sourceDebtCheck = WaiverProcessor::checkDebtsComprehensive((int) $waiver['source_member_id']);
$targetDebtCheck = $waiver['target_member_id'] ? WaiverProcessor::checkDebtsComprehensive((int) $waiver['target_member_id']) : null; $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);
}
// Already-saved individual fees
$individualFees = WaiverProcessor::getIndividualFees((int) $id);
return $this->view('Waiver.Views.show', [ return $this->view('Waiver.Views.show', [
'waiver' => $waiver, 'waiver' => $waiver,
'source_deps' => $sourceDeps, 'source_deps' => $sourceDeps,
...@@ -171,6 +180,8 @@ class WaiverController extends Controller ...@@ -171,6 +180,8 @@ class WaiverController extends Controller
'comparison' => $comparison, 'comparison' => $comparison,
'source_debt_check' => $sourceDebtCheck, 'source_debt_check' => $sourceDebtCheck,
'target_debt_check' => $targetDebtCheck, 'target_debt_check' => $targetDebtCheck,
'excess_individuals' => $excessIndividuals,
'individual_fees' => $individualFees,
]); ]);
} }
...@@ -182,17 +193,14 @@ class WaiverController extends Controller ...@@ -182,17 +193,14 @@ class WaiverController extends Controller
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$boardRef = trim($request->post('board_decision_reference', '')); $boardRef = trim($request->post('board_decision_reference', ''));
$membershipValue = $waiver['membership_value_at_waiver']; $membershipValue = (string) $waiver['membership_value_at_waiver'];
// Per-category fee configuration from board $originalDeps = [
$spouseFeeType = $request->post('spouse_fee_type', '') ?: null; 'spouses' => (int) ($waiver['original_spouses_count'] ?? 0),
$spouseFeeRate = trim($request->post('spouse_fee_rate', '')); 'children' => (int) ($waiver['original_children_count'] ?? 0),
$childFeeType = $request->post('child_fee_type', '') ?: null; 'temporary' => (int) ($waiver['original_temporary_count'] ?? 0),
$childFeeRate = trim($request->post('child_fee_rate', '')); ];
$tempFeeType = $request->post('temporary_fee_type', '') ?: null;
$tempFeeRate = trim($request->post('temporary_fee_rate', ''));
// Calculate per-category excess
$excessSpouses = 0; $excessSpouses = 0;
$excessChildren = 0; $excessChildren = 0;
$excessTemporary = 0; $excessTemporary = 0;
...@@ -200,33 +208,51 @@ class WaiverController extends Controller ...@@ -200,33 +208,51 @@ class WaiverController extends Controller
$childFeeTotal = '0.00'; $childFeeTotal = '0.00';
$tempFeeTotal = '0.00'; $tempFeeTotal = '0.00';
$originalDeps = [
'spouses' => (int) ($waiver['original_spouses_count'] ?? 0),
'children' => (int) ($waiver['original_children_count'] ?? 0),
'temporary' => (int) ($waiver['original_temporary_count'] ?? 0),
];
if ($waiver['target_member_id']) { if ($waiver['target_member_id']) {
$targetDeps = WaiverProcessor::countDependents((int) $waiver['target_member_id']); $targetDeps = WaiverProcessor::countDependents((int) $waiver['target_member_id']);
$comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps); $comparison = WaiverProcessor::compareDependents($originalDeps, $targetDeps);
$excessSpouses = $comparison['excess_spouses']; $excessSpouses = $comparison['excess_spouses'];
$excessChildren = $comparison['excess_children']; $excessChildren = $comparison['excess_children'];
$excessTemporary = $comparison['excess_temporary']; $excessTemporary = $comparison['excess_temporary'];
$feeConfig = [ // Collect per-individual fees from form (arrays indexed by person key)
'spouse_fee_type' => $spouseFeeType, $feeTypes = $request->post('ind_fee_type', []);
'spouse_fee_rate' => $spouseFeeRate, $feeRates = $request->post('ind_fee_rate', []);
'child_fee_type' => $childFeeType, $personTypes = $request->post('ind_person_type', []);
'child_fee_rate' => $childFeeRate, $personIds = $request->post('ind_person_id', []);
'temporary_fee_type' => $tempFeeType, $personNames = $request->post('ind_person_name', []);
'temporary_fee_rate' => $tempFeeRate, $personOrders = $request->post('ind_person_order', []);
$personAges = $request->post('ind_age_years', []);
$personAgeCodes = $request->post('ind_age_category_code', []);
$personDobs = $request->post('ind_date_of_birth', []);
$personRelations = $request->post('ind_relationship', []);
$personStatuses = $request->post('ind_status', []);
$feesData = [];
if (is_array($feeTypes)) {
foreach ($feeTypes as $idx => $fType) {
$feesData[] = [
'person_type' => $personTypes[$idx] ?? '',
'person_id' => (int) ($personIds[$idx] ?? 0),
'person_name' => $personNames[$idx] ?? '',
'person_order' => (int) ($personOrders[$idx] ?? 1),
'fee_type' => $fType ?: null,
'fee_rate' => $feeRates[$idx] ?? '0',
'age_years' => ($personAges[$idx] ?? '') !== '' ? (int) $personAges[$idx] : null,
'age_category_code' => $personAgeCodes[$idx] ?? null,
'date_of_birth' => $personDobs[$idx] ?? null,
'relationship' => $personRelations[$idx] ?? null,
'status' => $personStatuses[$idx] ?? null,
]; ];
}
}
$fees = WaiverProcessor::calculateExcessFees($comparison, $feeConfig, (string) $membershipValue); if (!empty($feesData)) {
$spouseFeeTotal = $fees['spouse_fee_total']; $totals = WaiverProcessor::saveIndividualFees((int) $id, $feesData, $membershipValue);
$childFeeTotal = $fees['child_fee_total']; $spouseFeeTotal = $totals['spouse_total'];
$tempFeeTotal = $fees['temporary_fee_total']; $childFeeTotal = $totals['child_total'];
$tempFeeTotal = $totals['temporary_total'];
}
} }
$totalExcessFee = bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2); $totalExcessFee = bcadd(bcadd($spouseFeeTotal, $childFeeTotal, 2), $tempFeeTotal, 2);
...@@ -243,14 +269,8 @@ class WaiverController extends Controller ...@@ -243,14 +269,8 @@ class WaiverController extends Controller
'excess_temporary_count' => $excessTemporary, 'excess_temporary_count' => $excessTemporary,
'excess_fee_percentage' => null, 'excess_fee_percentage' => null,
'excess_fee_amount' => $totalExcessFee, 'excess_fee_amount' => $totalExcessFee,
'spouse_fee_type' => $spouseFeeType,
'spouse_fee_rate' => ($spouseFeeRate !== '' && is_numeric($spouseFeeRate)) ? $spouseFeeRate : null,
'spouse_fee_total' => $spouseFeeTotal, 'spouse_fee_total' => $spouseFeeTotal,
'child_fee_type' => $childFeeType,
'child_fee_rate' => ($childFeeRate !== '' && is_numeric($childFeeRate)) ? $childFeeRate : null,
'child_fee_total' => $childFeeTotal, 'child_fee_total' => $childFeeTotal,
'temporary_fee_type' => $tempFeeType,
'temporary_fee_rate' => ($tempFeeRate !== '' && is_numeric($tempFeeRate)) ? $tempFeeRate : null,
'temporary_fee_total' => $tempFeeTotal, 'temporary_fee_total' => $tempFeeTotal,
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]; ];
......
...@@ -361,6 +361,180 @@ final class WaiverProcessor ...@@ -361,6 +361,180 @@ final class WaiverProcessor
return bcmul($perUnit, (string) $excessCount, 2); return bcmul($perUnit, (string) $excessCount, 2);
} }
/**
* Identify excess dependents (individual persons) for per-person fee assignment.
* Compares target dependents vs original counts — returns the EXTRA individuals.
*/
public static function getExcessIndividuals(int $targetMemberId, array $originalCounts): array
{
$db = App::getInstance()->db();
$today = new \DateTime();
$excess = [];
// Spouses: if target has more than original, the LAST ones are considered excess
$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;
if (count($spouses) > $allowedSpouses) {
$excessSpouses = array_slice($spouses, $allowedSpouses);
$order = 1;
foreach ($excessSpouses as $s) {
$excess[] = [
'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',
'age_years' => null,
'age_category' => null,
'age_category_code' => null,
'date_of_birth' => null,
'relationship' => null,
];
}
}
// Children: last N are excess
$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;
if (count($children) > $allowedChildren) {
$excessChildren = array_slice($children, $allowedChildren);
$order = 1;
foreach ($excessChildren as $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'; }
}
$excess[] = [
'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',
'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,
];
}
}
// Temporary: last N are excess
$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;
if (count($temps) > $allowedTemps) {
$excessTemps = array_slice($temps, $allowedTemps);
$order = 1;
foreach ($excessTemps as $t) {
$excess[] = [
'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',
'age_years' => null,
'age_category' => null,
'age_category_code' => null,
'date_of_birth' => null,
'relationship' => null,
];
}
}
return $excess;
}
/**
* Save per-individual fees set by the board.
*/
public static function saveIndividualFees(int $waiverId, array $fees, string $membershipValue): array
{
$db = App::getInstance()->db();
// Clear old fees for this waiver
$db->delete('waiver_individual_fees', '`waiver_request_id` = ?', [$waiverId]);
$spouseTotal = '0.00';
$childTotal = '0.00';
$tempTotal = '0.00';
foreach ($fees as $fee) {
$feeType = $fee['fee_type'] ?? null;
$feeRate = $fee['fee_rate'] ?? '0';
$feeAmount = '0.00';
if ($feeType === 'percentage' && is_numeric($feeRate)) {
$feeAmount = bcdiv(bcmul($membershipValue, (string) $feeRate, 4), '100', 2);
} elseif ($feeType === 'fixed' && is_numeric($feeRate)) {
$feeAmount = number_format((float) $feeRate, 2, '.', '');
}
$db->insert('waiver_individual_fees', [
'waiver_request_id' => $waiverId,
'person_type' => $fee['person_type'],
'person_id' => (int) $fee['person_id'],
'person_name' => $fee['person_name'] ?? '',
'person_order' => (int) ($fee['person_order'] ?? 1),
'fee_type' => $feeType ?: null,
'fee_rate' => is_numeric($feeRate) ? $feeRate : null,
'fee_amount' => $feeAmount,
'age_years' => $fee['age_years'] ?? null,
'age_category' => $fee['age_category_code'] ?? $fee['age_category'] ?? null,
'date_of_birth' => $fee['date_of_birth'] ?? null,
'relationship' => $fee['relationship'] ?? null,
'status' => $fee['status'] ?? null,
'notes' => $fee['notes'] ?? null,
]);
if ($fee['person_type'] === 'spouse') $spouseTotal = bcadd($spouseTotal, $feeAmount, 2);
elseif ($fee['person_type'] === 'child') $childTotal = bcadd($childTotal, $feeAmount, 2);
elseif ($fee['person_type'] === 'temporary') $tempTotal = bcadd($tempTotal, $feeAmount, 2);
}
$grandTotal = bcadd(bcadd($spouseTotal, $childTotal, 2), $tempTotal, 2);
return [
'spouse_total' => $spouseTotal,
'child_total' => $childTotal,
'temporary_total' => $tempTotal,
'grand_total' => $grandTotal,
];
}
/**
* Load saved per-individual fees for a waiver.
*/
public static function getIndividualFees(int $waiverId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM waiver_individual_fees WHERE waiver_request_id = ? ORDER BY person_type, person_order",
[$waiverId]
);
}
private static function getPersonLabel(?string $personType, string $personName): string private static function getPersonLabel(?string $personType, string $personName): string
{ {
return match ($personType) { return match ($personType) {
......
...@@ -49,7 +49,7 @@ ...@@ -49,7 +49,7 @@
</tfoot> </tfoot>
</table> </table>
<div style="margin-top:12px;display:flex;align-items:center;gap:12px;"> <div style="margin-top:12px;display:flex;align-items:center;gap:12px;">
<a href="/members/<?= (int) $member['id'] ?>/financial" class="btn btn-outline" style="padding:8px 16px;font-size:13px;color:#DC2626;border-color:#DC2626;"> <a href="/payments/process/<?= (int) $member['id'] ?>" class="btn btn-outline" style="padding:8px 16px;font-size:13px;color:#DC2626;border-color:#DC2626;">
الانتقال إلى السداد ← الانتقال إلى السداد ←
</a> </a>
<span style="font-size:12px;color:#7F1D1D;">يجب سداد جميع المبالغ المستحقة (بما فيها مديونيات التابعين) قبل تقديم طلب التنازل.</span> <span style="font-size:12px;color:#7F1D1D;">يجب سداد جميع المبالغ المستحقة (بما فيها مديونيات التابعين) قبل تقديم طلب التنازل.</span>
...@@ -188,7 +188,7 @@ ...@@ -188,7 +188,7 @@
</form> </form>
<?php else: ?> <?php else: ?>
<div style="padding:20px;text-align:center;"> <div style="padding:20px;text-align:center;">
<a href="/members/<?= (int) $member['id'] ?>/financial" class="btn btn-primary" style="background:#DC2626;">الانتقال إلى السداد</a> <a href="/payments/process/<?= (int) $member['id'] ?>" class="btn btn-primary" style="background:#DC2626;">الانتقال إلى السداد</a>
<a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">رجوع</a> <a href="/members/<?= (int) $member['id'] ?>" class="btn btn-outline">رجوع</a>
</div> </div>
<?php endif; ?> <?php endif; ?>
......
This diff is collapsed.
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS waiver_individual_fees (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
waiver_request_id BIGINT UNSIGNED NOT NULL,
person_type ENUM('spouse','child','temporary') NOT NULL,
person_id BIGINT UNSIGNED NOT NULL COMMENT 'FK to spouses/children/temporary_members',
person_name VARCHAR(200) NOT NULL,
person_order TINYINT UNSIGNED NOT NULL DEFAULT 1,
fee_type ENUM('fixed','percentage') NULL,
fee_rate DECIMAL(15,2) NULL COMMENT 'Fixed amount or percentage value',
fee_amount DECIMAL(15,2) NOT NULL DEFAULT 0.00 COMMENT 'Calculated fee for this person',
age_years INT UNSIGNED NULL COMMENT 'Age at time of waiver (children only)',
age_category VARCHAR(50) NULL COMMENT 'under_12, 12_to_16, 16_to_18, 18_to_25, 25_plus',
date_of_birth DATE NULL,
relationship VARCHAR(30) NULL COMMENT 'son/daughter for children',
status VARCHAR(50) NULL COMMENT 'active/frozen/etc',
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_waiver_request (waiver_request_id),
INDEX idx_person (person_type, person_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
",
'down' => "DROP TABLE IF EXISTS waiver_individual_fees;",
];
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