Commit 32e8dda5 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix membership payment sync, auto-send post-activation additions to cashier,...

Fix membership payment sync, auto-send post-activation additions to cashier, and add fee calculation transparency

- Fix critical bug in Cashier bootstrap where children/temporary_members activation
  failed silently due to non-existent join_date column (only spouses have it)
- Add self-healing sync in MemberController::show() to fix historically stuck dependents
- Auto-create payment_request when adding dependents to active members (post-activation)
- Add pending additions UI section for active members showing cashier queue status
- Store fee calculation breakdowns in new fee_breakdown_json column on dependent tables
- Display expandable fee breakdowns in family tree for full calculation transparency
- Add migration Phase_65_014 for fee_breakdown_json column
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 715fac64
...@@ -99,20 +99,24 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -99,20 +99,24 @@ EventBus::listen('payment_request.completed', function (array $data) {
[$memberId] [$memberId]
); );
foreach ($pendingDeps as $dep) { foreach ($pendingDeps as $dep) {
// Only activate if no separate pending payment request exists
$hasSeparateRequest = $db->selectOne( $hasSeparateRequest = $db->selectOne(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status IN ('pending','processing') AND is_voided = 0 LIMIT 1", "SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status IN ('pending','processing') AND is_voided = 0 LIMIT 1",
[$memberId, $depTable, (int) $dep['id']] [$memberId, $depTable, (int) $dep['id']]
); );
if (!$hasSeparateRequest) { if (!$hasSeparateRequest) {
$db->update($depTable, [ $updateData = [
'status' => 'active', 'status' => 'active',
'join_date' => date('Y-m-d'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $dep['id']]); ];
if ($depTable === 'spouses') {
$updateData['join_date'] = date('Y-m-d');
}
$db->update($depTable, $updateData, '`id` = ?', [(int) $dep['id']]);
} }
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {
\App\Core\Logger::error("Failed to activate {$depTable} for member {$memberId}: " . $e->getMessage());
}
} }
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]); EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
......
...@@ -13,6 +13,7 @@ use App\Modules\Children\Services\ChildFeeCalculator; ...@@ -13,6 +13,7 @@ use App\Modules\Children\Services\ChildFeeCalculator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Forms\Services\FormBridge; use App\Modules\Forms\Services\FormBridge;
use App\Modules\Cashier\Services\PaymentRequestService;
class ChildController extends Controller class ChildController extends Controller
...@@ -134,6 +135,8 @@ class ChildController extends Controller ...@@ -134,6 +135,8 @@ class ChildController extends Controller
$totalFee = $feeCalc['total_fee'] ?? $feeCalc['fee'] ?? '0.00'; $totalFee = $feeCalc['total_fee'] ?? $feeCalc['fee'] ?? '0.00';
$hasFee = bccomp($totalFee, '0', 2) > 0; $hasFee = bccomp($totalFee, '0', 2) > 0;
$breakdownJson = !empty($feeCalc['breakdown']) ? json_encode($feeCalc['breakdown'], JSON_UNESCAPED_UNICODE) : null;
$child = Child::create([ $child = Child::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'child_order' => $childOrder, 'child_order' => $childOrder,
...@@ -150,6 +153,7 @@ class ChildController extends Controller ...@@ -150,6 +153,7 @@ class ChildController extends Controller
'nationality' => $data['nationality'] ?? 'مصري', 'nationality' => $data['nationality'] ?? 'مصري',
'classification' => $classification, 'classification' => $classification,
'addition_fee' => $totalFee, 'addition_fee' => $totalFee,
'fee_breakdown_json' => $breakdownJson,
'status' => $hasFee ? 'pending_payment' : 'active', 'status' => $hasFee ? 'pending_payment' : 'active',
'remarks' => $data['remarks'] ?? null, 'remarks' => $data['remarks'] ?? null,
]); ]);
...@@ -165,6 +169,22 @@ class ChildController extends Controller ...@@ -165,6 +169,22 @@ class ChildController extends Controller
'fee' => $totalFee, 'fee' => $totalFee,
]); ]);
if ($hasFee && !empty($member['membership_number'])) {
$breakdown = $feeCalc['breakdown'] ?? [];
$childLabel = ($data['gender'] ?? '') === 'male' ? 'ابن' : 'ابنة';
PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $totalFee,
'payment_type' => 'addition_fee',
'related_entity_type' => 'children',
'related_entity_id' => (int) $child->id,
'description_ar' => 'رسوم إضافة ' . $childLabel . ' — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
return $this->redirect("/members/{$memberId}")
->withSuccess('تم إضافة الابن/الابنة — التصنيف: ' . $child->getClassificationLabel() . ' — الرسوم: ' . money($totalFee) . ' — تم إرسالها للخزينة تلقائياً');
}
if ($hasFee) { if ($hasFee) {
return $this->redirect("/members/{$memberId}") return $this->redirect("/members/{$memberId}")
->withSuccess('تم إضافة الابن/الابنة — التصنيف: ' . $child->getClassificationLabel() . ' — الرسوم: ' . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة'); ->withSuccess('تم إضافة الابن/الابنة — التصنيف: ' . $child->getClassificationLabel() . ' — الرسوم: ' . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة');
......
...@@ -20,7 +20,7 @@ class Child extends Model ...@@ -20,7 +20,7 @@ class Child extends Model
'national_id', 'birth_certificate_number', 'date_of_birth', 'national_id', 'birth_certificate_number', 'date_of_birth',
'age_years', 'age_months', 'gender', 'relationship', 'age_years', 'age_months', 'gender', 'relationship',
'school_faculty', 'nationality', 'classification', 'school_faculty', 'nationality', 'classification',
'addition_fee', 'fee_receipt_number', 'status', 'addition_fee', 'fee_breakdown_json', 'fee_receipt_number', 'status',
'is_frozen', 'frozen_at', 'frozen_reason', 'photo_path', 'remarks', 'is_frozen', 'frozen_at', 'frozen_reason', 'photo_path', 'remarks',
]; ];
......
...@@ -142,6 +142,41 @@ class MemberController extends Controller ...@@ -142,6 +142,41 @@ class MemberController extends Controller
try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e) {} try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e) {}
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e) {} try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e) {}
// Self-healing: fix dependents stuck at pending_payment after membership was paid
if ($member->status === 'active') {
$membershipPaid = $db->selectOne(
"SELECT id FROM payments WHERE member_id = ? AND payment_type IN ('membership_fee','down_payment') AND is_voided = 0 LIMIT 1",
[(int) $id]
);
if ($membershipPaid) {
$fixedAny = false;
foreach (['spouses', 'children', 'temporary_members'] as $tbl) {
$stuck = $db->select(
"SELECT id FROM `{$tbl}` WHERE member_id = ? AND status = 'pending_payment' AND is_archived = 0",
[(int) $id]
);
foreach ($stuck as $dep) {
$hasPending = $db->selectOne(
"SELECT id FROM payment_requests WHERE member_id = ? AND payment_type = 'addition_fee' AND related_entity_type = ? AND related_entity_id = ? AND status IN ('pending','processing') AND is_voided = 0 LIMIT 1",
[(int) $id, $tbl, (int) $dep['id']]
);
if (!$hasPending) {
$upd = ['status' => 'active', 'updated_at' => date('Y-m-d H:i:s')];
if ($tbl === 'spouses') $upd['join_date'] = date('Y-m-d');
$db->update($tbl, $upd, '`id` = ?', [(int) $dep['id']]);
$fixedAny = true;
}
}
}
if ($fixedAny) {
try { $spouses = $db->select("SELECT * FROM spouses WHERE member_id = ? AND is_archived = 0 ORDER BY spouse_order", [(int) $id]); } catch (\Throwable $e) {}
try { $children = $db->select("SELECT * FROM children WHERE member_id = ? AND is_archived = 0 ORDER BY child_order", [(int) $id]); } catch (\Throwable $e) {}
try { $temporaries = $db->select("SELECT * FROM temporary_members WHERE member_id = ? AND is_archived = 0 ORDER BY id", [(int) $id]); } catch (\Throwable $e) {}
Logger::info("Fixed stuck pending_payment dependants", ['member_id' => (int) $id]);
}
}
}
$bill = BillingService::getMemberBill((int) $id); $bill = BillingService::getMemberBill((int) $id);
$formFilled = ($member->qualification_id !== null && $member->qualification_id > 0); $formFilled = ($member->qualification_id !== null && $member->qualification_id > 0);
......
...@@ -133,6 +133,12 @@ final class BillingService ...@@ -133,6 +133,12 @@ final class BillingService
$fee = $s['addition_fee'] ?? '0.00'; $fee = $s['addition_fee'] ?? '0.00';
$isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0); $isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0);
$breakdown = null;
if (!empty($s['fee_breakdown_json'])) {
$breakdown = json_decode($s['fee_breakdown_json'], true);
if (!is_array($breakdown)) $breakdown = null;
}
$items[] = [ $items[] = [
'type' => 'spouse_fee', 'type' => 'spouse_fee',
'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'], 'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'],
...@@ -143,6 +149,7 @@ final class BillingService ...@@ -143,6 +149,7 @@ final class BillingService
'entity_type' => 'spouses', 'entity_type' => 'spouses',
'entity_id' => (int) $s['id'], 'entity_id' => (int) $s['id'],
'category' => 'addition', 'category' => 'addition',
'breakdown' => $breakdown,
]; ];
} }
...@@ -168,6 +175,12 @@ final class BillingService ...@@ -168,6 +175,12 @@ final class BillingService
default => $classification, default => $classification,
}; };
$breakdown = null;
if (!empty($c['fee_breakdown_json'])) {
$breakdown = json_decode($c['fee_breakdown_json'], true);
if (!is_array($breakdown)) $breakdown = null;
}
$items[] = [ $items[] = [
'type' => 'child_fee', 'type' => 'child_fee',
'label' => ($c['gender'] === 'male' ? 'ابن' : 'ابنة') . ' #' . $order . ' — ' . $c['full_name_ar'] . ' (' . (int) ($c['age_years'] ?? 0) . ' سنة)', 'label' => ($c['gender'] === 'male' ? 'ابن' : 'ابنة') . ' #' . $order . ' — ' . $c['full_name_ar'] . ' (' . (int) ($c['age_years'] ?? 0) . ' سنة)',
...@@ -178,6 +191,7 @@ final class BillingService ...@@ -178,6 +191,7 @@ final class BillingService
'entity_type' => 'children', 'entity_type' => 'children',
'entity_id' => (int) $c['id'], 'entity_id' => (int) $c['id'],
'category' => 'addition', 'category' => 'addition',
'breakdown' => $breakdown,
]; ];
} }
...@@ -188,6 +202,12 @@ final class BillingService ...@@ -188,6 +202,12 @@ final class BillingService
[$memberId] [$memberId]
); );
foreach ($temps as $t) { foreach ($temps as $t) {
$breakdown = null;
if (!empty($t['fee_breakdown_json'])) {
$breakdown = json_decode($t['fee_breakdown_json'], true);
if (!is_array($breakdown)) $breakdown = null;
}
$items[] = [ $items[] = [
'type' => 'temp_fee', 'type' => 'temp_fee',
'label' => 'عضو مؤقت — ' . $t['full_name_ar'] . ' (' . $t['category'] . ')', 'label' => 'عضو مؤقت — ' . $t['full_name_ar'] . ' (' . $t['category'] . ')',
...@@ -197,6 +217,7 @@ final class BillingService ...@@ -197,6 +217,7 @@ final class BillingService
'entity_type' => 'temporary_members', 'entity_type' => 'temporary_members',
'entity_id' => (int) $t['id'], 'entity_id' => (int) $t['id'],
'category' => 'addition', 'category' => 'addition',
'breakdown' => $breakdown,
]; ];
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
......
This diff is collapsed.
...@@ -12,6 +12,7 @@ use App\Modules\Spouses\Models\Spouse; ...@@ -12,6 +12,7 @@ use App\Modules\Spouses\Models\Spouse;
use App\Modules\Spouses\Services\SpouseFeeCalculator; use App\Modules\Spouses\Services\SpouseFeeCalculator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Rules\Services\RuleEngine; use App\Modules\Rules\Services\RuleEngine;
use App\Modules\Cashier\Services\PaymentRequestService;
class SpouseController extends Controller class SpouseController extends Controller
...@@ -150,6 +151,8 @@ class SpouseController extends Controller ...@@ -150,6 +151,8 @@ class SpouseController extends Controller
$totalFee = $feeCalc['total_fee'] ?? '0.00'; $totalFee = $feeCalc['total_fee'] ?? '0.00';
$hasFee = bccomp($totalFee, '0', 2) > 0; $hasFee = bccomp($totalFee, '0', 2) > 0;
$breakdownJson = !empty($feeCalc['breakdown']) ? json_encode($feeCalc['breakdown'], JSON_UNESCAPED_UNICODE) : null;
$spouse = Spouse::create([ $spouse = Spouse::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'spouse_order' => $spouseOrder, 'spouse_order' => $spouseOrder,
...@@ -172,6 +175,7 @@ class SpouseController extends Controller ...@@ -172,6 +175,7 @@ class SpouseController extends Controller
'join_date' => date('Y-m-d'), 'join_date' => date('Y-m-d'),
'classification' => 'working', 'classification' => 'working',
'addition_fee' => $totalFee, 'addition_fee' => $totalFee,
'fee_breakdown_json' => $breakdownJson,
'status' => $hasFee ? 'pending_payment' : 'active', 'status' => $hasFee ? 'pending_payment' : 'active',
]); ]);
...@@ -184,6 +188,21 @@ class SpouseController extends Controller ...@@ -184,6 +188,21 @@ class SpouseController extends Controller
$genderWord = $requiredGender === 'male' ? 'الزوج' : 'الزوجة'; $genderWord = $requiredGender === 'male' ? 'الزوج' : 'الزوجة';
if ($hasFee && !empty($member['membership_number'])) {
$breakdown = $feeCalc['breakdown'] ?? [];
PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $totalFee,
'payment_type' => 'addition_fee',
'related_entity_type' => 'spouses',
'related_entity_id' => (int) $spouse->id,
'description_ar' => 'رسوم إضافة ' . $genderWord . ' — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
return $this->redirect("/members/{$memberId}")
->withSuccess("تم إضافة {$genderWord} — الرسوم: " . money($totalFee) . ' — تم إرسالها للخزينة تلقائياً');
}
if ($hasFee) { if ($hasFee) {
return $this->redirect("/members/{$memberId}") return $this->redirect("/members/{$memberId}")
->withSuccess("تم إضافة {$genderWord} — الرسوم: " . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة'); ->withSuccess("تم إضافة {$genderWord} — الرسوم: " . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة');
......
...@@ -21,7 +21,7 @@ class Spouse extends Model ...@@ -21,7 +21,7 @@ class Spouse extends Model
'age_years', 'age_months', 'gender', 'nationality', 'religion', 'age_years', 'age_months', 'gender', 'nationality', 'religion',
'qualification_id', 'occupation', 'work_address', 'work_phone', 'mobile', 'qualification_id', 'occupation', 'work_address', 'work_phone', 'mobile',
'marriage_date', 'join_date', 'classification', 'addition_fee', 'marriage_date', 'join_date', 'classification', 'addition_fee',
'fee_receipt_number', 'status', 'photo_path', 'fee_breakdown_json', 'fee_receipt_number', 'status', 'photo_path',
]; ];
public static function getForMember(int $memberId): array public static function getForMember(int $memberId): array
......
...@@ -11,6 +11,7 @@ use App\Core\EventBus; ...@@ -11,6 +11,7 @@ use App\Core\EventBus;
use App\Modules\Temporary\Models\TemporaryMember; use App\Modules\Temporary\Models\TemporaryMember;
use App\Modules\Temporary\Services\TemporaryFeeCalculator; use App\Modules\Temporary\Services\TemporaryFeeCalculator;
use App\Modules\Members\Services\NationalIdParser; use App\Modules\Members\Services\NationalIdParser;
use App\Modules\Cashier\Services\PaymentRequestService;
class TemporaryController extends Controller class TemporaryController extends Controller
...@@ -133,6 +134,8 @@ class TemporaryController extends Controller ...@@ -133,6 +134,8 @@ class TemporaryController extends Controller
$totalFee = $feeCalc['total_fee'] ?? $feeCalc['fee'] ?? '0.00'; $totalFee = $feeCalc['total_fee'] ?? $feeCalc['fee'] ?? '0.00';
$hasFee = bccomp($totalFee, '0', 2) > 0; $hasFee = bccomp($totalFee, '0', 2) > 0;
$breakdownJson = !empty($feeCalc['breakdown']) ? json_encode($feeCalc['breakdown'], JSON_UNESCAPED_UNICODE) : null;
$temp = TemporaryMember::create([ $temp = TemporaryMember::create([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
'category' => $category, 'category' => $category,
...@@ -149,6 +152,7 @@ class TemporaryController extends Controller ...@@ -149,6 +152,7 @@ class TemporaryController extends Controller
'has_championship' => !empty($data['has_championship']) ? 1 : 0, 'has_championship' => !empty($data['has_championship']) ? 1 : 0,
'disability_documentation' => !empty($data['disability_documentation']) ? 1 : 0, 'disability_documentation' => !empty($data['disability_documentation']) ? 1 : 0,
'addition_fee' => $totalFee, 'addition_fee' => $totalFee,
'fee_breakdown_json' => $breakdownJson,
'can_separate' => TemporaryFeeCalculator::canSeparate($category) ? 1 : 0, 'can_separate' => TemporaryFeeCalculator::canSeparate($category) ? 1 : 0,
'can_get_independent' => TemporaryFeeCalculator::canGetIndependent($category) ? 1 : 0, 'can_get_independent' => TemporaryFeeCalculator::canGetIndependent($category) ? 1 : 0,
'status' => $hasFee ? 'pending_payment' : 'active', 'status' => $hasFee ? 'pending_payment' : 'active',
...@@ -162,6 +166,21 @@ class TemporaryController extends Controller ...@@ -162,6 +166,21 @@ class TemporaryController extends Controller
'fee' => $totalFee, 'fee' => $totalFee,
]); ]);
if ($hasFee && !empty($member['membership_number'])) {
$breakdown = $feeCalc['breakdown'] ?? [];
PaymentRequestService::createRequest([
'member_id' => (int) $memberId,
'amount' => $totalFee,
'payment_type' => 'addition_fee',
'related_entity_type' => 'temporary_members',
'related_entity_id' => (int) $temp->id,
'description_ar' => 'رسوم إضافة عضو مؤقت — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]);
return $this->redirect("/members/{$memberId}")
->withSuccess('تم إضافة العضو المؤقت — الرسوم: ' . money($totalFee) . ' — تم إرسالها للخزينة تلقائياً');
}
if ($hasFee) { if ($hasFee) {
return $this->redirect("/members/{$memberId}") return $this->redirect("/members/{$memberId}")
->withSuccess('تم إضافة العضو المؤقت — الرسوم: ' . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة'); ->withSuccess('تم إضافة العضو المؤقت — الرسوم: ' . money($totalFee) . ' — سيتم تحصيلها ضمن الفاتورة المجمعة');
......
...@@ -19,7 +19,8 @@ class TemporaryMember extends Model ...@@ -19,7 +19,8 @@ class TemporaryMember extends Model
'national_id', 'passport_number', 'date_of_birth', 'national_id', 'passport_number', 'date_of_birth',
'age_years', 'age_months', 'gender', 'nationality', 'age_years', 'age_months', 'gender', 'nationality',
'relationship_to_member', 'has_championship', 'disability_documentation', 'relationship_to_member', 'has_championship', 'disability_documentation',
'addition_fee', 'fee_receipt_number', 'can_separate', 'can_get_independent', 'addition_fee', 'fee_breakdown_json', 'fee_receipt_number',
'can_separate', 'can_get_independent',
'status', 'photo_path', 'notes', 'status', 'photo_path', 'notes',
]; ];
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE spouses ADD COLUMN fee_breakdown_json TEXT NULL AFTER addition_fee;
ALTER TABLE children ADD COLUMN fee_breakdown_json TEXT NULL AFTER addition_fee;
ALTER TABLE temporary_members ADD COLUMN fee_breakdown_json TEXT NULL AFTER addition_fee
",
'down' => "
ALTER TABLE spouses DROP COLUMN fee_breakdown_json;
ALTER TABLE children DROP COLUMN fee_breakdown_json;
ALTER TABLE temporary_members DROP COLUMN fee_breakdown_json
",
];
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