Commit 5ff91171 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent 75815e05
...@@ -23,13 +23,16 @@ class AuditController extends Controller ...@@ -23,13 +23,16 @@ class AuditController extends Controller
]; ];
$page = max(1, (int) $request->get('page', 1)); $page = max(1, (int) $request->get('page', 1));
$result = AuditTrail::search($filters, 30, $page); $result = AuditTrail::search($filters, 40, $page);
$actions = AuditTrail::getDistinctActions(); $actions = AuditTrail::getDistinctActions();
$entityTypes = AuditTrail::getDistinctEntityTypes(); $entityTypes = AuditTrail::getDistinctEntityTypes();
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar"); $employees = [];
try {
$employees = $db->select("SELECT id, full_name_ar FROM employees WHERE is_archived = 0 ORDER BY full_name_ar");
} catch (\Throwable $e) {}
return $this->view('Audit.Views.index', [ return $this->view('Audit.Views.index', [
'rows' => $result['data'], 'rows' => $result['data'],
...@@ -41,14 +44,44 @@ class AuditController extends Controller ...@@ -41,14 +44,44 @@ class AuditController extends Controller
]); ]);
} }
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$entry = $db->selectOne("SELECT * FROM audit_trail WHERE id = ?", [(int) $id]);
if (!$entry) {
return $this->redirect('/audit')->withError('السجل غير موجود');
}
$prevEntry = $db->selectOne(
"SELECT id FROM audit_trail WHERE entity_type = ? AND entity_id = ? AND id < ? ORDER BY id DESC LIMIT 1",
[$entry['entity_type'], $entry['entity_id'], (int) $id]
);
$nextEntry = $db->selectOne(
"SELECT id FROM audit_trail WHERE entity_type = ? AND entity_id = ? AND id > ? ORDER BY id ASC LIMIT 1",
[$entry['entity_type'], $entry['entity_id'], (int) $id]
);
return $this->view('Audit.Views.show', [
'entry' => $entry,
'prevId' => $prevEntry ? (int) $prevEntry['id'] : null,
'nextId' => $nextEntry ? (int) $nextEntry['id'] : null,
]);
}
public function entityHistory(Request $request, string $type, string $id): Response public function entityHistory(Request $request, string $type, string $id): Response
{ {
$history = AuditTrail::getEntityHistory($type, (int) $id); $history = AuditTrail::getEntityHistory($type, (int) $id);
$entityLabel = null;
if (!empty($history)) {
$entityLabel = $history[0]['entity_label'] ?? null;
}
return $this->view('Audit.Views.entity-history', [ return $this->view('Audit.Views.entity-history', [
'entityType' => $type, 'entityType' => $type,
'entityId' => (int) $id, 'entityId' => (int) $id,
'history' => $history, 'entityLabel' => $entityLabel,
'history' => $history,
]); ]);
} }
} }
\ No newline at end of file
...@@ -3,5 +3,6 @@ declare(strict_types=1); ...@@ -3,5 +3,6 @@ declare(strict_types=1);
return [ return [
['GET', '/audit', 'Audit\Controllers\AuditController@index', ['auth'], 'report.view_audit'], ['GET', '/audit', 'Audit\Controllers\AuditController@index', ['auth'], 'report.view_audit'],
['GET', '/audit/{id:\d+}', 'Audit\Controllers\AuditController@show', ['auth'], 'report.view_audit'],
['GET', '/audit/entity/{type}/{id:\d+}', 'Audit\Controllers\AuditController@entityHistory', ['auth'], 'report.view_audit'], ['GET', '/audit/entity/{type}/{id:\d+}', 'Audit\Controllers\AuditController@entityHistory', ['auth'], 'report.view_audit'],
]; ];
\ No newline at end of file
...@@ -300,4 +300,195 @@ final class AuditService ...@@ -300,4 +300,195 @@ final class AuditService
} catch (\Throwable $e2) {} } catch (\Throwable $e2) {}
} }
} }
public static function getActionLabel(string $action): string
{
return match ($action) {
'create' => 'إنشاء',
'update' => 'تعديل',
'delete' => 'حذف',
'archive' => 'أرشفة',
'login' => 'تسجيل دخول',
'logout' => 'تسجيل خروج',
'login_failed' => 'فشل تسجيل دخول',
default => $action,
};
}
public static function getActionIcon(string $action): string
{
return match ($action) {
'create' => '➕',
'update' => '✏️',
'delete' => '🗑️',
'archive' => '📦',
'login' => '🔑',
'logout' => '🚪',
'login_failed' => '🚫',
default => '📋',
};
}
public static function getActionColor(string $action): string
{
return match ($action) {
'create' => '#059669',
'update' => '#0284C7',
'delete' => '#DC2626',
'archive' => '#D97706',
'login' => '#6366F1',
'logout' => '#6B7280',
'login_failed' => '#DC2626',
default => '#6B7280',
};
}
public static function getEntityTypeLabel(string $type): string
{
return match ($type) {
'members' => 'عضو',
'employees' => 'موظف',
'spouses' => 'زوج/ة',
'children' => 'ابن/ابنة',
'temporary_members' => 'عضو مؤقت',
'seasonal_memberships' => 'عضوية موسمية',
'payments' => 'دفعة',
'payment_requests' => 'طلب دفع',
'receipts' => 'إيصال',
'installment_plans' => 'خطة تقسيط',
'installment_schedule' => 'قسط',
'subscriptions' => 'اشتراك',
'fines' => 'غرامة',
'divorce_cases' => 'حالة طلاق',
'death_cases' => 'حالة وفاة',
'waiver_requests' => 'طلب تنازل',
'transfer_requests' => 'طلب نقل/فصل',
'branches' => 'فرع',
'roles' => 'دور وظيفي',
'qualifications' => 'مؤهل',
'business_rules' => 'قاعدة عمل',
'service_catalog' => 'خدمة',
'form_submissions' => 'استمارة',
'workflow_instances' => 'مسار عمل',
'journal_entries' => 'قيد محاسبي',
'sports_members' => 'عضو رياضي',
'carnets' => 'كارنيه',
'employee' => 'موظف',
default => $type,
};
}
public static function getFieldLabel(string $field): string
{
return match ($field) {
'full_name_ar' => 'الاسم بالعربية',
'full_name_en' => 'الاسم بالإنجليزية',
'national_id' => 'الرقم القومي',
'status' => 'الحالة',
'membership_number' => 'رقم العضوية',
'form_number' => 'رقم الاستمارة',
'branch_id' => 'الفرع',
'qualification_id' => 'المؤهل',
'membership_value' => 'قيمة العضوية',
'membership_type' => 'نوع العضوية',
'member_category' => 'فئة العضو',
'date_of_birth' => 'تاريخ الميلاد',
'gender' => 'النوع',
'phone' => 'الهاتف',
'email' => 'البريد الإلكتروني',
'marital_status' => 'الحالة الاجتماعية',
'nationality' => 'الجنسية',
'religion' => 'الديانة',
'occupation' => 'المهنة',
'residence_address' => 'عنوان السكن',
'is_archived' => 'مؤرشف',
'archived_at' => 'تاريخ الأرشفة',
'archived_by' => 'أرشف بواسطة',
'created_at' => 'تاريخ الإنشاء',
'updated_at' => 'تاريخ التعديل',
'created_by' => 'أنشئ بواسطة',
'updated_by' => 'عُدّل بواسطة',
'amount' => 'المبلغ',
'payment_type' => 'نوع الدفعة',
'payment_method' => 'طريقة الدفع',
'payment_date' => 'تاريخ الدفع',
'receipt_number' => 'رقم الإيصال',
'request_number' => 'رقم الطلب',
'addition_fee' => 'رسوم الإضافة',
'spouse_order' => 'ترتيب الزوجة',
'child_order' => 'ترتيب الابن',
'classification' => 'التصنيف',
'join_date' => 'تاريخ الالتحاق',
'marriage_date' => 'تاريخ الزواج',
'age_years' => 'السن (سنوات)',
'age_months' => 'السن (شهور)',
'category' => 'الفئة',
'notes' => 'ملاحظات',
'is_voided' => 'ملغى',
'voided_at' => 'تاريخ الإلغاء',
'void_reason' => 'سبب الإلغاء',
'description_ar' => 'الوصف',
'current_value_json' => 'القيمة الحالية',
'rule_code' => 'كود القاعدة',
'fee_amount' => 'مبلغ الرسوم',
'start_date' => 'تاريخ البداية',
'end_date' => 'تاريخ النهاية',
'discount_amount' => 'مبلغ الخصم',
'special_discount_id' => 'خصم خاص',
'has_championship' => 'بطولات رياضية',
'disability_documentation' => 'وثائق إعاقة',
'relationship_to_member' => 'صلة القرابة',
'passport_number' => 'رقم جواز السفر',
'can_separate' => 'يمكن الفصل',
'can_get_independent' => 'يمكن الاستقلال',
'processed_by' => 'معالج بواسطة',
'processed_at' => 'تاريخ المعالجة',
'requested_by' => 'مقدم الطلب',
'related_entity_type' => 'نوع الكيان المرتبط',
'related_entity_id' => 'رقم الكيان المرتبط',
default => $field,
};
}
public static function getStatusLabel(string $status): string
{
return match ($status) {
'active' => 'نشط',
'inactive' => 'غير نشط',
'potential' => 'محتمل',
'under_review' => 'قيد المراجعة',
'accepted' => 'مقبول',
'rejected' => 'مرفوض',
'payment_pending' => 'في انتظار الدفع',
'pending_payment' => 'في انتظار الدفع',
'pending' => 'معلق',
'processing' => 'قيد المعالجة',
'completed' => 'مكتمل',
'cancelled' => 'ملغى',
'frozen' => 'مجمد',
'suspended' => 'موقوف',
'dropped' => 'منسحب',
'expired' => 'منتهي',
default => $status,
};
}
public static function formatFieldValue(string $field, mixed $value): string
{
if ($value === null || $value === '') return '—';
if (in_array($field, ['amount', 'membership_value', 'addition_fee', 'fee_amount', 'discount_amount', 'total_amount', 'down_payment', 'remaining_balance', 'monthly_payment'], true)) {
return number_format((float) $value, 2) . ' ج.م';
}
if ($field === 'status') return self::getStatusLabel((string) $value);
if ($field === 'gender') return match ((string) $value) { 'male' => 'ذكر', 'female' => 'أنثى', default => (string) $value };
if (in_array($field, ['is_archived', 'is_voided', 'has_championship', 'disability_documentation', 'can_separate', 'can_get_independent'], true)) {
return $value ? 'نعم' : 'لا';
}
if ($field === 'payment_type') {
return \App\Modules\Cashier\Services\PaymentRequestService::getPaymentTypeLabel((string) $value);
}
return mb_substr((string) $value, 0, 200);
}
} }
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -52,7 +52,29 @@ $statusLabel = match($pr['status']) { ...@@ -52,7 +52,29 @@ $statusLabel = match($pr['status']) {
<td style="padding:8px 0;"><?= e($pr['description_ar']) ?></td> <td style="padding:8px 0;"><?= e($pr['description_ar']) ?></td>
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php if ($pr['notes']): ?> <?php
$notesData = $pr['notes'] ? json_decode($pr['notes'], true) : null;
$feeBreakdown = $notesData['fee_breakdown'] ?? null;
?>
<?php if (is_array($feeBreakdown) && !empty($feeBreakdown)): ?>
<tr>
<td colspan="2" style="padding:12px 0;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:12px 16px;">
<div style="font-weight:700;color:#1A1A2E;margin-bottom:8px;font-size:13px;">تفاصيل المبلغ:</div>
<?php foreach ($feeBreakdown as $line): ?>
<?php if (str_contains($line, '═══')): ?>
<hr style="border:0;border-top:1px solid #D1D5DB;margin:6px 0;">
<?php elseif (str_starts_with($line, '💵')): ?>
<div style="font-weight:700;color:#059669;font-size:14px;margin-top:4px;"><?= e($line) ?></div>
<?php else: ?>
<div style="font-size:13px;color:#374151;padding:2px 0;"><?= e($line) ?></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endif; ?>
<?php if ($pr['notes'] && !is_array($notesData)): ?>
<tr> <tr>
<td style="padding:8px 0;color:#6B7280;">ملاحظات</td> <td style="padding:8px 0;color:#6B7280;">ملاحظات</td>
<td style="padding:8px 0;"><?= e($pr['notes']) ?></td> <td style="padding:8px 0;"><?= e($pr['notes']) ?></td>
......
...@@ -108,7 +108,28 @@ ...@@ -108,7 +108,28 @@
</td> </td>
<td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td> <td style="font-weight:600;color:#D97706;"><?= e($r['form_number'] ?? '—') ?></td>
<td><?= $typeLabel ?></td> <td><?= $typeLabel ?></td>
<td style="direction:ltr;text-align:right;font-weight:700;font-size:15px;"><?= money($r['amount']) ?></td> <td style="direction:ltr;text-align:right;font-weight:700;font-size:15px;">
<?= money($r['amount']) ?>
<?php
$nd = $r['notes'] ? json_decode($r['notes'], true) : null;
$fb = $nd['fee_breakdown'] ?? null;
if (is_array($fb) && !empty($fb)):
$summary = [];
foreach ($fb as $ln) {
if (str_contains($ln, '═══') || str_starts_with($ln, '💵')) continue;
$summary[] = $ln;
}
?>
<div style="font-size:11px;font-weight:400;color:#6B7280;margin-top:2px;max-width:220px;line-height:1.4;">
<?php foreach (array_slice($summary, 0, 3) as $sl): ?>
<div><?= e($sl) ?></div>
<?php endforeach; ?>
<?php if (count($summary) > 3): ?>
<div style="color:#9CA3AF;">+<?= count($summary) - 3 ?> بنود أخرى</div>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td> <td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td>
<td style="font-size:12px;<?= $urgent ? 'color:#DC2626;font-weight:700;' : 'color:#6B7280;' ?>"><?= $waitText ?></td> <td style="font-size:12px;<?= $urgent ? 'color:#DC2626;font-weight:700;' : 'color:#6B7280;' ?>"><?= $waitText ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['requested_by_name'] ?? '—') ?></td> <td style="font-size:12px;color:#6B7280;"><?= e($r['requested_by_name'] ?? '—') ?></td>
......
...@@ -91,6 +91,30 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -91,6 +91,30 @@ EventBus::listen('payment_request.completed', function (array $data) {
} }
} }
// Activate all pending_payment dependants included in the lump sum
foreach (['spouses', 'children', 'temporary_members'] as $depTable) {
try {
$pendingDeps = $db->select(
"SELECT id FROM `{$depTable}` WHERE member_id = ? AND status = 'pending_payment' AND is_archived = 0",
[$memberId]
);
foreach ($pendingDeps as $dep) {
// Only activate if no separate pending payment request exists
$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",
[$memberId, $depTable, (int) $dep['id']]
);
if (!$hasSeparateRequest) {
$db->update($depTable, [
'status' => 'active',
'join_date' => date('Y-m-d'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $dep['id']]);
}
}
} catch (\Throwable $e) {}
}
EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]); EventBus::dispatch('member.activated', ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
\App\Core\Logger::info("Member activated via cashier", ['member_id' => $memberId, 'membership_number' => $membershipNumber]); \App\Core\Logger::info("Member activated via cashier", ['member_id' => $memberId, 'membership_number' => $membershipNumber]);
} }
......
...@@ -167,6 +167,7 @@ class ChildController extends Controller ...@@ -167,6 +167,7 @@ class ChildController extends Controller
'related_entity_type' => 'children', 'related_entity_type' => 'children',
'related_entity_id' => (int) $child->id, 'related_entity_id' => (int) $child->id,
'description_ar' => 'رسوم إضافة ' . $childLabel . ' — ' . trim($data['full_name_ar']), 'description_ar' => 'رسوم إضافة ' . $childLabel . ' — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $feeCalc['breakdown'] ?? []], JSON_UNESCAPED_UNICODE),
]); ]);
if ($payResult['success']) { if ($payResult['success']) {
......
...@@ -54,8 +54,36 @@ final class ChildFeeCalculator ...@@ -54,8 +54,36 @@ final class ChildFeeCalculator
} }
$childFee = $feeResult['fee'] ?? '0.00'; $childFee = $feeResult['fee'] ?? '0.00';
$percentage = $feeResult['percentage'] ?? '0.00';
$ruleApplied = $feeResult['rule_applied'] ?? '';
$formFee = FormFeeService::getFormFee($memberId, $member); $formFee = FormFeeService::getFormFee($memberId, $member);
$totalFee = bcadd($childFee, $formFee, 2);
$classLabels = [
'included' => 'مشمول (بدون رسوم)',
'dependent_with_fee' => 'تابع (برسوم)',
'temporary' => 'مؤقت',
];
$breakdown = [];
$breakdown[] = '📋 القاعدة المطبقة: ' . ($ruleApplied ?: 'تلقائي');
$breakdown[] = '💰 قيمة العضوية: ' . money($membershipValue);
$breakdown[] = '👶 الترتيب: #' . $childOrder . ' — السن: ' . $childAge . ' سنة';
$breakdown[] = '📌 التصنيف: ' . ($classLabels[$classification] ?? $classification);
if ($classification === 'included') {
$breakdown[] = '✅ مشمول في قيمة العضوية — بدون رسوم إضافية';
} else {
if (bccomp($childFee, '0', 2) > 0) {
$breakdown[] = '📊 نسبة ' . $percentage . '% × ' . money($membershipValue) . ' = ' . money($childFee);
}
if (bccomp($formFee, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($totalFee);
return [ return [
'child_order' => $childOrder, 'child_order' => $childOrder,
...@@ -64,11 +92,12 @@ final class ChildFeeCalculator ...@@ -64,11 +92,12 @@ final class ChildFeeCalculator
'membership_value' => $membershipValue, 'membership_value' => $membershipValue,
'classification' => $classification, 'classification' => $classification,
'fee' => $childFee, 'fee' => $childFee,
'percentage' => $feeResult['percentage'] ?? '0.00', 'percentage' => $percentage,
'form_fee' => $formFee, 'form_fee' => $formFee,
'total_fee' => bcadd($childFee, $formFee, 2), 'total_fee' => $totalFee,
'rule_applied' => $feeResult['rule_applied'] ?? '', 'rule_applied' => $ruleApplied,
'error' => null, 'error' => null,
'breakdown' => $breakdown,
]; ];
} }
} }
...@@ -168,13 +168,23 @@ class MemberController extends Controller ...@@ -168,13 +168,23 @@ class MemberController extends Controller
$member = Member::find((int) $id); $member = Member::find((int) $id);
if (!$member) return $this->redirect('/members')->withError('العضو غير موجود'); if (!$member) return $this->redirect('/members')->withError('العضو غير موجود');
$formFeeAmount = MemberNumberGenerator::getFormFee();
$breakdown = [
'📋 رسوم استمارة عضوية جديدة',
'💰 رسوم الاستمارة: 500.00 ج.م',
'🏛️ طابع شهداء: 5.00 ج.م',
'═══════════════════════════',
'💵 الإجمالي: ' . money($formFeeAmount),
];
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $id, 'member_id' => (int) $id,
'amount' => MemberNumberGenerator::getFormFee(), 'amount' => $formFeeAmount,
'payment_type' => 'form_fee', 'payment_type' => 'form_fee',
'related_entity_type' => 'members', 'related_entity_type' => 'members',
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''), 'description_ar' => 'رسوم استمارة عضوية رقم ' . ($member->form_number ?? ''),
'notes' => json_encode(['fee_breakdown' => $breakdown], JSON_UNESCAPED_UNICODE),
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']); return $this->redirect('/members/' . $id)->withSuccess('تم إرسال طلب الدفع للخزينة — رقم الطلب: ' . $result['request_number']);
...@@ -191,7 +201,23 @@ class MemberController extends Controller ...@@ -191,7 +201,23 @@ class MemberController extends Controller
if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح'); if (bccomp($amount, '0.01', 2) < 0) return $this->redirect('/members/' . $id)->withError('المبلغ غير صالح');
$months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null; $months = ($paymentType === 'down_payment') ? min(30, max(1, (int) $request->post('installment_months', 30))) : null;
$notes = $months ? json_encode(['installment_months' => $months], JSON_UNESCAPED_UNICODE) : null;
$bill = BillingService::getMemberBill((int) $id);
$breakdown = [];
foreach ($bill['items'] ?? [] as $item) {
if ($item['paid'] || $item['included']) continue;
$breakdown[] = ($item['category'] === 'discount' ? '🏷️ ' : '📌 ') . $item['label'] . ': ' . money($item['amount']);
}
$breakdown[] = '═══════════════════════════';
if ($paymentType === 'down_payment' && $months) {
$breakdown[] = '💵 مقدم التقسيط: ' . money($amount);
$breakdown[] = '📅 عدد الأشهر: ' . $months;
} else {
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($amount);
}
$notesData = ['fee_breakdown' => $breakdown];
if ($months) $notesData['installment_months'] = $months;
$result = PaymentRequestService::createRequest([ $result = PaymentRequestService::createRequest([
'member_id' => (int) $id, 'member_id' => (int) $id,
...@@ -200,7 +226,7 @@ class MemberController extends Controller ...@@ -200,7 +226,7 @@ class MemberController extends Controller
'related_entity_type' => 'members', 'related_entity_type' => 'members',
'related_entity_id' => (int) $id, 'related_entity_id' => (int) $id,
'description_ar' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''), 'description_ar' => ($paymentType === 'down_payment' ? 'مقدم تقسيط' : 'قيمة العضوية') . ' — استمارة ' . ($member->form_number ?? ''),
'notes' => $notes, 'notes' => json_encode($notesData, JSON_UNESCAPED_UNICODE),
]); ]);
if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']); if (!$result['success']) return $this->redirect('/members/' . $id)->withError($result['error']);
......
...@@ -50,11 +50,12 @@ final class BillingService ...@@ -50,11 +50,12 @@ final class BillingService
$items[] = [ $items[] = [
'type' => 'form_fee', 'type' => 'form_fee',
'label' => 'رسوم استمارة عضوية (500 استمارة + 5 طابع شهداء)', 'label' => 'رسوم استمارة عضوية',
'amount' => $formFeeAmount, 'amount' => $formFeeAmount,
'paid' => $formFeePaid, 'paid' => $formFeePaid,
'included' => false, 'included' => false,
'category' => 'required', 'category' => 'required',
'breakdown' => ['رسوم الاستمارة: 500.00 ج.م', 'طابع شهداء: 5.00 ج.م'],
]; ];
// ── 2. Membership Value ── // ── 2. Membership Value ──
...@@ -132,15 +133,15 @@ final class BillingService ...@@ -132,15 +133,15 @@ final class BillingService
$isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0); $isFirstFree = ($order === 1 && bccomp($fee, '0', 2) <= 0);
$items[] = [ $items[] = [
'type' => 'spouse_fee', 'type' => 'spouse_fee',
'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'], 'label' => 'زوجة #' . $order . ' — ' . $s['full_name_ar'],
'amount' => $isFirstFree ? '0.00' : $fee, 'amount' => $isFirstFree ? '0.00' : $fee,
'paid' => false, 'paid' => false,
'included' => $isFirstFree, 'included' => $isFirstFree,
'included_note' => $isFirstFree ? 'مشمولة في قيمة العضوية' : null, 'included_note' => $isFirstFree ? 'مشمولة في قيمة العضوية' : null,
'entity_type' => 'spouses', 'entity_type' => 'spouses',
'entity_id' => (int) $s['id'], 'entity_id' => (int) $s['id'],
'category' => 'addition', 'category' => 'addition',
]; ];
} }
...@@ -217,22 +218,34 @@ final class BillingService ...@@ -217,22 +218,34 @@ final class BillingService
} }
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
// ── Check payment_requests for completed or pending additions ── // ── Check payment_requests for completed or pending additions + load breakdowns ──
try { try {
$additionRequests = $db->select( $additionRequests = $db->select(
"SELECT related_entity_type, related_entity_id, status FROM payment_requests "SELECT related_entity_type, related_entity_id, status, notes, payment_type FROM payment_requests
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0", WHERE member_id = ? AND is_voided = 0",
[$memberId] [$memberId]
); );
foreach ($additionRequests as $ar) { foreach ($additionRequests as $ar) {
foreach ($items as &$item) { foreach ($items as &$item) {
if (($item['entity_type'] ?? '') === $ar['related_entity_type'] $matchesEntity = ($item['entity_type'] ?? '') === ($ar['related_entity_type'] ?? '')
&& ($item['entity_id'] ?? 0) == $ar['related_entity_id']) { && ($item['entity_id'] ?? 0) == ($ar['related_entity_id'] ?? 0)
&& ($ar['payment_type'] === 'addition_fee');
$matchesType = !isset($item['entity_type'])
&& (($item['type'] === 'form_fee' && $ar['payment_type'] === 'form_fee')
|| ($item['type'] === 'membership_fee' && in_array($ar['payment_type'], ['membership_fee', 'down_payment'], true)));
if ($matchesEntity || $matchesType) {
if ($ar['status'] === 'completed') { if ($ar['status'] === 'completed') {
$item['paid'] = true; $item['paid'] = true;
} elseif (in_array($ar['status'], ['pending', 'processing'], true)) { } elseif (in_array($ar['status'], ['pending', 'processing'], true)) {
$item['in_queue'] = true; $item['in_queue'] = true;
} }
if (!empty($ar['notes'])) {
$nd = json_decode($ar['notes'], true);
if (is_array($nd) && !empty($nd['fee_breakdown'])) {
$item['breakdown'] = $nd['fee_breakdown'];
}
}
} }
} }
unset($item); unset($item);
......
...@@ -13,10 +13,8 @@ use App\Modules\ServiceCatalog\Models\ServicePrice; ...@@ -13,10 +13,8 @@ use App\Modules\ServiceCatalog\Models\ServicePrice;
* Business rule: * Business rule:
* - During initial membership creation (member status = potential / under_review), * - During initial membership creation (member status = potential / under_review),
* all additions are on the same initial form — no additional form fee. * all additions are on the same initial form — no additional form fee.
* - After activation, any modification needs a "form addition" (570 EGP). * - After activation (member has membership number), any addition/modification/removal
* - Multiple additions within the SAME form session share one form fee. * requires a 570 EGP form fee PER PERSON. No session sharing.
* A form session is "open" if a form fee was paid (or is pending) within
* the configured validity window (default 30 days).
*/ */
final class FormFeeService final class FormFeeService
{ {
...@@ -28,43 +26,10 @@ final class FormFeeService ...@@ -28,43 +26,10 @@ final class FormFeeService
return in_array($member['status'] ?? '', ['potential', 'under_review'], true); return in_array($member['status'] ?? '', ['potential', 'under_review'], true);
} }
/**
* Check if there's an active (open) addition form session for this member.
*
* An addition form is "open" if any addition_fee payment request was created
* within the configured validity window. The form fee is embedded in the first
* addition's total — subsequent additions within the same window share it.
*/
public static function hasOpenAdditionForm(int $memberId): bool
{
$db = App::getInstance()->db();
$validityDays = (int) (RuleEngine::getValue('ADDITION_FORM_VALIDITY_DAYS', 'value') ?? 30);
$cutoff = date('Y-m-d H:i:s', strtotime("-{$validityDays} days"));
$existing = $db->selectOne(
"SELECT id FROM payment_requests
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0
AND status IN ('pending', 'processing', 'completed')
AND created_at >= ?
LIMIT 1",
[$memberId, $cutoff]
);
if ($existing) return true;
$paid = $db->selectOne(
"SELECT id FROM payments
WHERE member_id = ? AND payment_type = 'addition_fee' AND is_voided = 0
AND created_at >= ?
LIMIT 1",
[$memberId, $cutoff]
);
return $paid !== null;
}
/** /**
* Get the form fee that should be charged for an addition. * Get the form fee that should be charged for an addition.
* Returns '0.00' if on initial form or if an addition form is already open. * Returns '0.00' if on initial form.
* Returns 570 EGP (or configured amount) per person after activation.
*/ */
public static function getFormFee(int $memberId, array $member): string public static function getFormFee(int $memberId, array $member): string
{ {
...@@ -72,10 +37,6 @@ final class FormFeeService ...@@ -72,10 +37,6 @@ final class FormFeeService
return '0.00'; return '0.00';
} }
if (self::hasOpenAdditionForm($memberId)) {
return '0.00';
}
$feeData = RuleEngine::get('FORM_ADDITION_FEE'); $feeData = RuleEngine::get('FORM_ADDITION_FEE');
return $feeData['amount'] ?? ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00'); return $feeData['amount'] ?? ServicePrice::getPrice('SVC_ADDITION_FORM', '570.00');
} }
......
...@@ -112,13 +112,16 @@ $pendingAdditions ??= []; ...@@ -112,13 +112,16 @@ $pendingAdditions ??= [];
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($bill['items'] as $item): ?> <?php foreach ($bill['items'] as $bIdx => $item): ?>
<tr style="border-bottom:1px solid #F3F4F6;"> <tr style="border-bottom:1px solid #F3F4F6;<?= !empty($item['breakdown']) ? 'cursor:pointer;' : '' ?>" <?= !empty($item['breakdown']) ? 'onclick="toggleBillDetail(' . $bIdx . ')"' : '' ?>>
<td style="padding:10px;"> <td style="padding:10px;">
<?= e($item['label']) ?> <?= e($item['label']) ?>
<?php if (!empty($item['included_note'])): ?> <?php if (!empty($item['included_note'])): ?>
<small style="display:block;color:#059669;font-size:11px;"><?= e($item['included_note']) ?></small> <small style="display:block;color:#059669;font-size:11px;"><?= e($item['included_note']) ?></small>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($item['breakdown'])): ?>
<small style="display:block;color:#0D7377;font-size:11px;">&#x25BC; اضغط لعرض التفاصيل</small>
<?php endif; ?>
</td> </td>
<td style="padding:10px;text-align:left;font-weight:600;direction:ltr;<?= $item['included'] ? 'color:#9CA3AF;text-decoration:line-through;' : '' ?>"> <td style="padding:10px;text-align:left;font-weight:600;direction:ltr;<?= $item['included'] ? 'color:#9CA3AF;text-decoration:line-through;' : '' ?>">
<?= money($item['amount']) ?> <?= money($item['amount']) ?>
...@@ -135,6 +138,23 @@ $pendingAdditions ??= []; ...@@ -135,6 +138,23 @@ $pendingAdditions ??= [];
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
<?php if (!empty($item['breakdown'])): ?>
<tr id="bill-detail-<?= $bIdx ?>" style="display:none;">
<td colspan="3" style="padding:0 10px 10px;">
<div style="background:#F9FAFB;border:1px solid #E5E7EB;border-radius:8px;padding:10px 14px;font-size:12px;">
<?php foreach ($item['breakdown'] as $line): ?>
<?php if (str_contains($line, '═══')): ?>
<hr style="border:0;border-top:1px solid #D1D5DB;margin:4px 0;">
<?php elseif (str_starts_with($line, '💵')): ?>
<div style="font-weight:700;color:#059669;font-size:13px;"><?= e($line) ?></div>
<?php else: ?>
<div style="color:#374151;padding:1px 0;"><?= e($line) ?></div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
<tfoot> <tfoot>
...@@ -651,4 +671,12 @@ $categoryLabels = [ ...@@ -651,4 +671,12 @@ $categoryLabels = [
</div> </div>
</div> </div>
<script>
function toggleBillDetail(idx) {
var el = document.getElementById('bill-detail-' + idx);
if (!el) return;
el.style.display = el.style.display === 'none' ? '' : 'none';
}
</script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
\ No newline at end of file
...@@ -171,6 +171,7 @@ class TemporaryController extends Controller ...@@ -171,6 +171,7 @@ class TemporaryController extends Controller
'related_entity_type' => 'temporary_members', 'related_entity_type' => 'temporary_members',
'related_entity_id' => (int) $temp->id, 'related_entity_id' => (int) $temp->id,
'description_ar' => 'رسوم إضافة عضو مؤقت (' . $catLabel . ') — ' . trim($data['full_name_ar']), 'description_ar' => 'رسوم إضافة عضو مؤقت (' . $catLabel . ') — ' . trim($data['full_name_ar']),
'notes' => json_encode(['fee_breakdown' => $feeCalc['breakdown'] ?? []], JSON_UNESCAPED_UNICODE),
]); ]);
if ($payResult['success']) { if ($payResult['success']) {
......
...@@ -45,16 +45,37 @@ final class TemporaryFeeCalculator ...@@ -45,16 +45,37 @@ final class TemporaryFeeCalculator
$fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2); $fee = bcmul($membershipValue, bcdiv($pct, '100', 4), 2);
$formFee = FormFeeService::getFormFee($memberId, $member); $formFee = FormFeeService::getFormFee($memberId, $member);
$totalFee = bcadd($fee, $formFee, 2);
$category = $tempData['category'] ?? '—';
$catLabels = [
'parent' => 'والد/ة', 'special_needs' => 'ذوي احتياجات خاصة',
'unmarried_daughter' => 'ابنة غير متزوجة', 'sister' => 'شقيقة',
'stepchild' => 'ابن/ة زوج', 'orphan' => 'يتيم',
'disabled_sibling' => 'شقيق معاق', 'nanny' => 'مربية',
];
$breakdown = [];
$breakdown[] = '📋 القاعدة المطبقة: TEMP_MEMBER_FEE';
$breakdown[] = '💰 قيمة العضوية: ' . money($membershipValue);
$breakdown[] = '📌 الفئة: ' . ($catLabels[$category] ?? $category);
$breakdown[] = '📊 نسبة ' . $pct . '% × ' . money($membershipValue) . ' = ' . money($fee);
if (bccomp($formFee, '0', 2) > 0) {
$breakdown[] = '📝 رسوم استمارة إضافة: ' . money($formFee);
}
$breakdown[] = '═══════════════════════════';
$breakdown[] = '💵 الإجمالي المطلوب: ' . money($totalFee);
return [ return [
'membership_value' => $membershipValue, 'membership_value' => $membershipValue,
'fee' => $fee, 'fee' => $fee,
'percentage' => $pct, 'percentage' => $pct,
'form_fee' => $formFee, 'form_fee' => $formFee,
'total_fee' => bcadd($fee, $formFee, 2), 'total_fee' => $totalFee,
'rule_applied' => 'TEMP_MEMBER_FEE', 'rule_applied' => 'TEMP_MEMBER_FEE',
'exempt' => false, 'exempt' => false,
'error' => null, 'error' => null,
'breakdown' => $breakdown,
]; ];
} }
......
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