Commit 9c97004c authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix payment void cascade, enhance death workflow, and transfer same-number...

Fix payment void cascade, enhance death workflow, and transfer same-number with companion validation

- Payment void now also voids linked payment_requests and reverts frozen/suspended members
- Death case requires full form fill for spouse, charges 570+annual, supports secondary wives as separate memberships
- Transfers keep the SAME membership number, archive source, charge surcharge for extra companions
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent b4b78902
...@@ -58,14 +58,14 @@ EventBus::listen('payment.voided', function (array $data) { ...@@ -58,14 +58,14 @@ EventBus::listen('payment.voided', function (array $data) {
); );
if (!$otherPayment) { if (!$otherPayment) {
$member = \App\Modules\Members\Models\Member::find($memberId); $member = \App\Modules\Members\Models\Member::find($memberId);
if ($member && $member->status === 'active') { if ($member && in_array($member->status, ['active', 'frozen', 'suspended'], true)) {
$member->update([ $member->update([
'status' => 'payment_pending', 'status' => 'payment_pending',
'membership_number' => null, 'membership_number' => null,
]); ]);
foreach (['spouses', 'children', 'temporary_members'] as $depTable) { foreach (['spouses', 'children', 'temporary_members'] as $depTable) {
$db->query( $db->query(
"UPDATE `{$depTable}` SET status = 'pending_payment', updated_at = NOW() WHERE member_id = ? AND status = 'active' AND is_archived = 0", "UPDATE `{$depTable}` SET status = 'pending_payment', join_date = NULL, updated_at = NOW() WHERE member_id = ? AND status IN ('active','frozen') AND is_archived = 0",
[$memberId] [$memberId]
); );
} }
......
...@@ -18,7 +18,9 @@ class DeathCase extends Model ...@@ -18,7 +18,9 @@ class DeathCase extends Model
protected static array $fillable = [ protected static array $fillable = [
'member_id', 'deceased_type', 'death_date', 'member_id', 'deceased_type', 'death_date',
'death_certificate_number', 'death_certificate_path', 'death_certificate_number', 'death_certificate_path',
'spouse_id', 'child_id', 'transferred_to_member_id', 'spouse_id', 'primary_spouse_id', 'secondary_spouses_json',
'primary_spouse_form_filled', 'child_id',
'children_assignment_json', 'transferred_to_member_id',
'same_membership_number', 'fee_amount', 'same_membership_number', 'fee_amount',
'archive_snapshot_id', 'workflow_instance_id', 'status', 'notes', 'archive_snapshot_id', 'workflow_instance_id', 'status', 'notes',
]; ];
......
...@@ -2,10 +2,12 @@ ...@@ -2,10 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
return [ return [
['GET', '/death', 'Death\Controllers\DeathController@index', ['auth'], 'transfer.view'], ['GET', '/death', 'Death\Controllers\DeathController@index', ['auth'], 'transfer.view'],
['GET', '/death/create/{memberId}', 'Death\Controllers\DeathController@create', ['auth'], 'transfer.initiate'], ['GET', '/death/create/{memberId}', 'Death\Controllers\DeathController@create', ['auth'], 'transfer.initiate'],
['POST', '/death/store/{memberId}', 'Death\Controllers\DeathController@store', ['auth', 'csrf'], 'transfer.initiate'], ['POST', '/death/store/{memberId}', 'Death\Controllers\DeathController@store', ['auth', 'csrf'], 'transfer.initiate'],
['GET', '/death/{id}', 'Death\Controllers\DeathController@show', ['auth'], 'transfer.view'], ['GET', '/death/{id}', 'Death\Controllers\DeathController@show', ['auth'], 'transfer.view'],
['POST', '/death/{id}/pay', 'Death\Controllers\DeathController@pay', ['auth', 'csrf'], 'payment.collect'], ['GET', '/death/{id}/fill-form', 'Death\Controllers\DeathController@fillForm', ['auth'], 'transfer.initiate'],
['POST', '/death/{id}/complete', 'Death\Controllers\DeathController@complete', ['auth', 'csrf'], 'transfer.approve'], ['POST', '/death/{id}/fill-form', 'Death\Controllers\DeathController@saveFillForm', ['auth', 'csrf'], 'transfer.initiate'],
['POST', '/death/{id}/pay', 'Death\Controllers\DeathController@pay', ['auth', 'csrf'], 'payment.collect'],
['POST', '/death/{id}/complete', 'Death\Controllers\DeathController@complete', ['auth', 'csrf'], 'transfer.approve'],
]; ];
\ No newline at end of file
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>استمارة عضوية جديدة — نقل وفاة #<?= (int) $case['id'] ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:15px;background:#F0FDFA;border:2px solid #0D7377;">
<strong style="color:#0D7377;">نقل عضوية بسبب وفاة</strong>
الزوجة <?= e($spouse['full_name_ar'] ?? '—') ?> ترث العضوية رقم <?= e($member['membership_number'] ?? '—') ?>
<br><small style="color:#6B7280;">يجب ملء جميع البيانات المطلوبة لاستمارة العضوية الجديدة</small>
</div>
<form method="POST" action="/death/<?= (int) $case['id'] ?>/fill-form">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">البيانات الشخصية</h3></div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">الاسم بالكامل (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="full_name_ar" value="<?= e($spouse['full_name_ar'] ?? '') ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="full_name_en" value="<?= e($spouse['full_name_en'] ?? '') ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">الرقم القومي <span style="color:#DC2626;">*</span></label>
<input type="text" name="national_id" value="<?= e($spouse['national_id'] ?? '') ?>" class="form-input" required maxlength="14">
</div>
<div class="form-group">
<label class="form-label">تاريخ الميلاد <span style="color:#DC2626;">*</span></label>
<input type="date" name="date_of_birth" value="<?= e($spouse['date_of_birth'] ?? '') ?>" class="form-input" required>
</div>
<div class="form-group">
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="gender" class="form-select" required>
<option value="male" <?= ($spouse['gender'] ?? '') === 'male' ? 'selected' : '' ?>>ذكر</option>
<option value="female" <?= ($spouse['gender'] ?? '') === 'female' ? 'selected' : '' ?>>أنثى</option>
</select>
</div>
<div class="form-group">
<label class="form-label">الجنسية</label>
<select name="nationality" class="form-select">
<option value="مصري">مصري</option>
<?php foreach ($countries as $c): ?>
<?php if ($c['nationality_ar'] !== 'مصري'): ?>
<option value="<?= e($c['nationality_ar']) ?>" <?= ($spouse['nationality'] ?? '') === $c['nationality_ar'] ? 'selected' : '' ?>><?= e($c['nationality_ar']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الديانة</label>
<select name="religion" class="form-select">
<option value="">-- اختر --</option>
<option value="مسلم">مسلم</option>
<option value="مسيحي">مسيحي</option>
</select>
</div>
<div class="form-group">
<label class="form-label">الحالة الاجتماعية</label>
<select name="marital_status" class="form-select">
<option value="">-- اختر --</option>
<option value="أرملة" selected>أرملة</option>
<option value="متزوج">متزوج/ة</option>
<option value="أعزب">أعزب</option>
</select>
</div>
<div class="form-group">
<label class="form-label">المؤهل <span style="color:#DC2626;">*</span></label>
<select name="qualification_id" class="form-select" required>
<option value="">-- اختر المؤهل --</option>
<?php foreach ($qualifications as $q): ?>
<option value="<?= (int) $q['id'] ?>"><?= e($q['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">بيانات الاتصال</h3></div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">الهاتف المحمول</label>
<input type="text" name="phone_mobile" value="<?= e($spouse['mobile'] ?? '') ?>" class="form-input">
</div>
<div class="form-group">
<label class="form-label">هاتف المنزل</label>
<input type="text" name="phone_home" class="form-input">
</div>
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" class="form-input">
</div>
<div class="form-group">
<label class="form-label">اسم شخص للطوارئ</label>
<input type="text" name="emergency_name" class="form-input">
</div>
<div class="form-group">
<label class="form-label">هاتف الطوارئ</label>
<input type="text" name="emergency_phone" class="form-input">
</div>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">العنوان والعمل</h3></div>
<div style="padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group" style="grid-column:1/-1;">
<label class="form-label">عنوان الإقامة</label>
<input type="text" name="residence_address" class="form-input">
</div>
<div class="form-group">
<label class="form-label">المنطقة</label>
<input type="text" name="area" class="form-input">
</div>
<div class="form-group">
<label class="form-label">المحافظة</label>
<select name="governorate" class="form-select">
<option value="">-- اختر --</option>
<?php foreach ($governorates as $g): ?>
<option value="<?= e($g['name_ar']) ?>"><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">الوظيفة</label>
<input type="text" name="occupation" class="form-input">
</div>
<div class="form-group">
<label class="form-label">المسمى الوظيفي</label>
<input type="text" name="job_title" class="form-input">
</div>
</div>
</div>
<div style="display:flex;gap:10px;">
<button type="submit" class="btn btn-primary">حفظ الاستمارة</button>
<a href="/death/<?= (int) $case['id'] ?>" class="btn btn-outline">← العودة</a>
</div>
</form>
<?php $__template->endSection(); ?>
This diff is collapsed.
...@@ -212,6 +212,12 @@ final class PaymentService ...@@ -212,6 +212,12 @@ final class PaymentService
], '`id` = ?', [(int) $payment['receipt_id']]); ], '`id` = ?', [(int) $payment['receipt_id']]);
} }
// Void any linked payment_requests that reference this payment
$db->query(
"UPDATE payment_requests SET status = 'voided', is_voided = 1, updated_at = NOW() WHERE payment_id = ? AND is_voided = 0",
[$paymentId]
);
$db->commit(); $db->commit();
EventBus::dispatch('payment.voided', [ EventBus::dispatch('payment.voided', [
......
...@@ -100,24 +100,52 @@ class TransferController extends Controller ...@@ -100,24 +100,52 @@ class TransferController extends Controller
return $this->redirect("/transfers/create/{$memberId}")->withError($feeCalc['error'] ?? 'خطأ في حساب الرسوم'); return $this->redirect("/transfers/create/{$memberId}")->withError($feeCalc['error'] ?? 'خطأ في حساب الرسوم');
} }
// Companion validation: check if new owner brings extra dependents
$targetSpousesCount = (int) $request->post('target_spouses_count', 0);
$targetChildrenCount = (int) $request->post('target_children_count', 0);
$companionSurcharge = '0.00';
$companionBreakdown = null;
$sourceCompanionsCount = null;
$targetCompanionsCount = null;
if ($targetSpousesCount > 0 || $targetChildrenCount > 0) {
$surchargeResult = SeparationFeeCalculator::calculateCompanionSurcharge(
(int) $memberId,
$targetSpousesCount,
$targetChildrenCount
);
$companionSurcharge = $surchargeResult['surcharge'];
$sourceCompanionsCount = $surchargeResult['source_count'];
$targetCompanionsCount = $surchargeResult['target_count'];
if (!empty($surchargeResult['breakdown'])) {
$companionBreakdown = json_encode($surchargeResult['breakdown'], JSON_UNESCAPED_UNICODE);
}
}
$totalWithSurcharge = bcadd($feeCalc['total_fee'], $companionSurcharge, 2);
$employee = App::getInstance()->currentEmployee(); $employee = App::getInstance()->currentEmployee();
$transferReq = TransferRequest::create([ $transferReq = TransferRequest::create([
'source_member_id' => (int) $memberId, 'source_member_id' => (int) $memberId,
'transfer_type' => $transferType, 'transfer_type' => $transferType,
'child_id' => $childId, 'child_id' => $childId,
'spouse_id' => $spouseId, 'spouse_id' => $spouseId,
'source_membership_number'=> $member['membership_number'], 'source_membership_number' => $member['membership_number'],
'new_membership_value' => $feeCalc['new_membership_value'], 'new_membership_value' => $feeCalc['new_membership_value'],
'years_since_acquisition' => $feeCalc['years_since_acquisition'], 'source_companions_count' => $sourceCompanionsCount,
'qualification_code' => $feeCalc['qualification_code'], 'target_companions_count' => $targetCompanionsCount,
'fee_percentage' => $feeCalc['fee_percentage'], 'companion_surcharge' => $companionSurcharge,
'separation_fee' => $feeCalc['separation_fee'], 'companion_surcharge_breakdown'=> $companionBreakdown,
'form_fee' => $feeCalc['form_fee'], 'years_since_acquisition' => $feeCalc['years_since_acquisition'],
'annual_subscription_fee' => $feeCalc['annual_subscription_fee'], 'qualification_code' => $feeCalc['qualification_code'],
'total_fee' => $feeCalc['total_fee'], 'fee_percentage' => $feeCalc['fee_percentage'],
'status' => 'requested', 'separation_fee' => $feeCalc['separation_fee'],
'notes' => $notes ?: null, 'form_fee' => $feeCalc['form_fee'],
'annual_subscription_fee' => $feeCalc['annual_subscription_fee'],
'total_fee' => $totalWithSurcharge,
'status' => 'requested',
'notes' => $notes ?: null,
]); ]);
if (FormBridge::exists('TRANSFER_SEPARATION')) { if (FormBridge::exists('TRANSFER_SEPARATION')) {
...@@ -131,7 +159,7 @@ class TransferController extends Controller ...@@ -131,7 +159,7 @@ class TransferController extends Controller
]); ]);
// Send payment to cashier queue // Send payment to cashier queue
$amount = $feeCalc['total_fee'] ?? '0.00'; $amount = $totalWithSurcharge;
if (bccomp((string) $amount, '0', 2) > 0) { if (bccomp((string) $amount, '0', 2) > 0) {
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $memberId, 'member_id' => (int) $memberId,
...@@ -153,7 +181,7 @@ class TransferController extends Controller ...@@ -153,7 +181,7 @@ class TransferController extends Controller
); );
} }
return $this->redirect("/transfers/{$transferReq->id}")->withSuccess('تم تقديم طلب التحويل/الفصل — الإجمالي: ' . money($feeCalc['total_fee'])); return $this->redirect("/transfers/{$transferReq->id}")->withSuccess('تم تقديم طلب التحويل/الفصل — الإجمالي: ' . money($totalWithSurcharge));
} }
public function show(Request $request, string $id): Response public function show(Request $request, string $id): Response
......
...@@ -19,6 +19,8 @@ class TransferRequest extends Model ...@@ -19,6 +19,8 @@ class TransferRequest extends Model
'source_member_id', 'target_member_id', 'transfer_type', 'source_member_id', 'target_member_id', 'transfer_type',
'child_id', 'spouse_id', 'source_membership_number', 'child_id', 'spouse_id', 'source_membership_number',
'new_membership_number', 'new_membership_value', 'new_membership_number', 'new_membership_value',
'source_companions_count', 'target_companions_count',
'companion_surcharge', 'companion_surcharge_breakdown',
'years_since_acquisition', 'qualification_code', 'years_since_acquisition', 'qualification_code',
'fee_percentage', 'separation_fee', 'form_fee', 'fee_percentage', 'separation_fee', 'form_fee',
'annual_subscription_fee', 'total_fee', 'annual_subscription_fee', 'total_fee',
......
...@@ -121,4 +121,78 @@ final class SeparationFeeCalculator ...@@ -121,4 +121,78 @@ final class SeparationFeeCalculator
$data = RuleEngine::require($ruleCode); $data = RuleEngine::require($ruleCode);
return $data['percentage']; return $data['percentage'];
} }
/**
* Calculate surcharge when new owner has more companions than the original member.
* Extra spouses/children are charged standard addition fees.
*/
public static function calculateCompanionSurcharge(
int $sourceMemberId,
int $targetSpousesCount,
int $targetChildrenCount
): array {
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT * FROM members WHERE id = ?", [$sourceMemberId]);
if (!$member) {
return ['surcharge' => '0.00', 'breakdown' => [], 'source_count' => 0, 'target_count' => 0];
}
$sourceSpouses = (int) $db->selectOne(
"SELECT COUNT(*) as cnt FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$sourceMemberId]
)['cnt'];
$sourceChildren = (int) $db->selectOne(
"SELECT COUNT(*) as cnt FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'",
[$sourceMemberId]
)['cnt'];
$sourceTotal = $sourceSpouses + $sourceChildren;
$targetTotal = $targetSpousesCount + $targetChildrenCount;
$breakdown = [];
$surcharge = '0.00';
$membershipValue = $member['membership_value'] ?? '0.00';
// Extra spouses beyond what source had
$extraSpouses = max(0, $targetSpousesCount - $sourceSpouses);
if ($extraSpouses > 0) {
$spouseBasePct = RuleEngine::getValue('SPOUSE_BASE_MEMBER_FEE', 'percentage') ?? '30.00';
$perSpouseFee = bcdiv(bcmul($membershipValue, $spouseBasePct, 4), '100', 2);
$spouseSurcharge = bcmul($perSpouseFee, (string) $extraSpouses, 2);
$surcharge = bcadd($surcharge, $spouseSurcharge, 2);
$breakdown[] = [
'type' => 'extra_spouses',
'count' => $extraSpouses,
'per_unit' => $perSpouseFee,
'percentage' => $spouseBasePct,
'total' => $spouseSurcharge,
];
}
// Extra children beyond what source had
$extraChildren = max(0, $targetChildrenCount - $sourceChildren);
if ($extraChildren > 0) {
$childBasePct = RuleEngine::getValue('CHILD_ADDITION_FEE', 'percentage') ?? '10.00';
$perChildFee = bcdiv(bcmul($membershipValue, $childBasePct, 4), '100', 2);
$childSurcharge = bcmul($perChildFee, (string) $extraChildren, 2);
$surcharge = bcadd($surcharge, $childSurcharge, 2);
$breakdown[] = [
'type' => 'extra_children',
'count' => $extraChildren,
'per_unit' => $perChildFee,
'percentage' => $childBasePct,
'total' => $childSurcharge,
];
}
return [
'surcharge' => $surcharge,
'breakdown' => $breakdown,
'source_count' => $sourceTotal,
'target_count' => $targetTotal,
'extra_spouses' => $extraSpouses ?? 0,
'extra_children' => $extraChildren ?? 0,
];
}
} }
\ No newline at end of file
...@@ -94,10 +94,22 @@ final class TransferProcessor ...@@ -94,10 +94,22 @@ final class TransferProcessor
'created_by' => $employee ? (int) $employee->id : null, 'created_by' => $employee ? (int) $employee->id : null,
]; ];
// Transfer the SAME membership number to the new member
$sameNumber = $sourceMember['membership_number'];
$newMemberData['membership_number'] = $sameNumber;
$newMemberId = $db->insert('members', $newMemberData); $newMemberId = $db->insert('members', $newMemberData);
// 4. Assign new membership number // 4. Clear membership number from source and archive
$newNumber = MemberNumberGenerator::assign($newMemberId); $db->update('members', [
'membership_number' => null,
'status' => 'transferred',
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $sourceMember['id']]);
$newNumber = $sameNumber;
// 5. Record number chain // 5. Record number chain
ArchiveService::recordNumberTransfer( ArchiveService::recordNumberTransfer(
...@@ -108,7 +120,7 @@ final class TransferProcessor ...@@ -108,7 +120,7 @@ final class TransferProcessor
null null
); );
// 6. Update source records // 6. Update source dependents — mark as separated if it's a child/spouse separation
if ($request['child_id']) { if ($request['child_id']) {
$db->update('children', [ $db->update('children', [
'status' => 'separated', 'status' => 'separated',
...@@ -122,6 +134,13 @@ final class TransferProcessor ...@@ -122,6 +134,13 @@ final class TransferProcessor
], '`id` = ?', [(int) $request['spouse_id']]); ], '`id` = ?', [(int) $request['spouse_id']]);
} }
// Transfer all dependents from source to new member (full membership transfer)
if (!$request['child_id'] && !$request['spouse_id']) {
$db->query("UPDATE spouses SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
$db->query("UPDATE children SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
$db->query("UPDATE temporary_members SET member_id = ?, updated_at = NOW() WHERE member_id = ? AND is_archived = 0", [$newMemberId, (int) $sourceMember['id']]);
}
// 7. Mark transfer request complete // 7. Mark transfer request complete
$db->update('transfer_requests', [ $db->update('transfer_requests', [
'target_member_id' => $newMemberId, 'target_member_id' => $newMemberId,
......
<?php
declare(strict_types=1);
return function (\App\Core\Database $db): void {
$table = 'death_cases';
$columnsToAdd = [
'primary_spouse_id' => "BIGINT UNSIGNED NULL AFTER `spouse_id`",
'secondary_spouses_json' => "TEXT NULL AFTER `primary_spouse_id`",
'primary_spouse_form_filled' => "TINYINT(1) NOT NULL DEFAULT 0 AFTER `secondary_spouses_json`",
'child_id' => "BIGINT UNSIGNED NULL AFTER `primary_spouse_form_filled`",
];
foreach ($columnsToAdd as $col => $definition) {
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[$table, $col]
);
if (!$exists) {
$db->raw("ALTER TABLE `{$table}` ADD COLUMN `{$col}` {$definition}");
}
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$db->raw("
ALTER TABLE transfer_requests
ADD COLUMN source_companions_count INT UNSIGNED NULL AFTER new_membership_value,
ADD COLUMN target_companions_count INT UNSIGNED NULL AFTER source_companions_count,
ADD COLUMN companion_surcharge DECIMAL(15,2) NOT NULL DEFAULT 0.00 AFTER target_companions_count,
ADD COLUMN companion_surcharge_breakdown TEXT NULL AFTER companion_surcharge
");
};
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