Commit 366460fd authored by DevPilot's avatar DevPilot

fix(payments, cashier, treasury): void-type gaps, unpaid fines via queue,...

fix(payments, cashier, treasury): void-type gaps, unpaid fines via queue, invisible guest payments, missing permission checks

Payments/Installments:
- PaymentLifecycleService::onPaymentVoided() ما كانتش بترجع حالة العضو
  إلا لـ membership_fee/down_payment بس — رغم إن foreign_membership_fee
  وsports_membership_fee وseasonal_fee كلهم بينشّطوا العضو، إلغاء أي واحدة
  منهم كان بيسيب العضو "فعال" للأبد من غير أي غطاء دفع حقيقي.
  MembershipPaymentGuard::deactivateMember() نفسها كانت أصلًا عارفة تتعامل
  مع الخمس أنواع صح — المشكلة كانت في القايمة اللي بتقرر تنادي عليها.
- غرامة بتترسل لطابور الدفع (fine) كانت بتتحصّل وتتطبع إيصال، لكن صف
  الغرامة في جدول fines فضل زي ما هو "غير مسدد" للأبد — مفيش حد كان بيسمع
  الحدث ده أصلًا. أي فحص رصيد أو أهلية كارنيه كان هيفضل يقول إن الغرامة
  لسه مستحقة حتى بعد سدادها فعليًا. اتضاف مستمع بيقفل الغرامة المحددة.
- GET /api/v1/payments كان مكسور تمامًا — بيفلتر بعمود is_archived مش
  موجود أصلًا في جدول payments (الصح is_voided).
- شاشة كل المدفوعات والتقرير اليومي كانوا بيستخدموا INNER JOIN مع
  members، فأي دفعة لعميل زائر (member_id فاضي — زي تسجيل رياضي لغير
  عضو) كانت تختفي تمامًا من الشاشتين دول رغم إنها محصّلة فعليًا
  (23 دفعة بحوالي 41,800 جنيه في البيانات الحية). اتحول لـ LEFT JOIN.
- زرار إلغاء الدفعة كان بيتحكم بصلاحية payment.void_receipt بينما الـ
  route بتاعه محتاج payment.void — محدش في الأدوار الحية عنده الصلاحية
  التانية، فكان عمليًا محدش غير super_admin يقدر يلغي دفعة من الشاشة دي.
- عضوية موسمية بتتفعّل من كاشير مباشرة من غير ما تعدّي على
  activateIncludedDependents — يعني زوجة/أبناء العضو الموسمي المرفقين في
  نفس الرسم ما كانوش بيتفعّلوا معاه.
- حذف DefaultChecker.php الميتة تمامًا (صفر استدعاء) — نفس منطق فحص
  التعثر بالظبط موجود وشغّال فعليًا في cron/jobs/InstallmentDefaultJob.php.

Treasury/Cashier:
- شاشات موديول Treasury بالكامل كانت من غير أي فحص can() على الإطلاق —
  كل الأزرار بتظهر لأي حد شايف الشاشة بغض النظر عن صلاحياته الحقيقية.
  اتأكد إن ده مش نظري: أدوار حية زي auditor وtreasury_manager وmain_cashier
  عندهم جزء من صلاحيات الخزنة بس مش كلها، فكانوا بياخدوا 403 على أزرار
  شايفينها. كل زرار دلوقتي بيتحقق من نفس صلاحية الـ route بتاعه.
  نفس الحاجة اتصلحت في زرار فتح/قفل وردية كاشير، وزرار طباعة الإيصال اللي
  كان بيتحكم بصلاحية receipt.print بدل payment.view الحقيقية.
- عداد "طلبات معلقة" في لوحة الخزنة الفرعية كان بيستخدم قايمة أنواع دفع
  أقدم من القايمة الحقيقية المستخدمة في الطابور نفسه — ناقصة رسوم عضوية
  أجانب/رياضية وبعض أنواع الأنشطة الرياضية.

Plus extreme-detail reference docs for Payments/Installments, Cashier, and Treasury.
parent d3f2e87e
...@@ -15,23 +15,27 @@ ...@@ -15,23 +15,27 @@
<span style="font-size:13px;color:#374151;">إيصالات: <strong><?= (int)($currentSession['total_receipts'] ?? 0) ?></strong></span> <span style="font-size:13px;color:#374151;">إيصالات: <strong><?= (int)($currentSession['total_receipts'] ?? 0) ?></strong></span>
<span style="font-size:13px;color:#374151;">العهدة: <strong><?= money((float)($custodyBalance ?? 0)) ?></strong></span> <span style="font-size:13px;color:#374151;">العهدة: <strong><?= money((float)($custodyBalance ?? 0)) ?></strong></span>
</div> </div>
<?php if (can('cashier.process_payment')): ?>
<form method="POST" action="/cashier/session/close" style="margin:0;" onsubmit="return confirm('هل تريد إغلاق الوردية الحالية؟');"> <form method="POST" action="/cashier/session/close" style="margin:0;" onsubmit="return confirm('هل تريد إغلاق الوردية الحالية؟');">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;font-size:12px;"> <button type="submit" class="btn btn-sm" style="background:#D97706;color:#fff;font-size:12px;">
<i data-lucide="lock" style="width:13px;height:13px;vertical-align:middle;"></i> إغلاق الوردية <i data-lucide="lock" style="width:13px;height:13px;vertical-align:middle;"></i> إغلاق الوردية
</button> </button>
</form> </form>
<?php endif; ?>
<?php else: ?> <?php else: ?>
<div style="display:flex;align-items:center;gap:10px;"> <div style="display:flex;align-items:center;gap:10px;">
<span style="background:#FEF3C7;color:#D97706;padding:4px 12px;border-radius:8px;font-size:12px;font-weight:700;">لا توجد وردية مفتوحة</span> <span style="background:#FEF3C7;color:#D97706;padding:4px 12px;border-radius:8px;font-size:12px;font-weight:700;">لا توجد وردية مفتوحة</span>
<span style="font-size:13px;color:#6B7280;">يجب فتح وردية قبل التحصيل</span> <span style="font-size:13px;color:#6B7280;">يجب فتح وردية قبل التحصيل</span>
</div> </div>
<?php if (can('cashier.process_payment')): ?>
<form method="POST" action="/cashier/session/open" style="margin:0;"> <form method="POST" action="/cashier/session/open" style="margin:0;">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;font-size:12px;"> <button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;font-size:12px;">
<i data-lucide="unlock" style="width:13px;height:13px;vertical-align:middle;"></i> فتح وردية <i data-lucide="unlock" style="width:13px;height:13px;vertical-align:middle;"></i> فتح وردية
</button> </button>
</form> </form>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
...@@ -212,7 +216,7 @@ ...@@ -212,7 +216,7 @@
<?php endif; ?> <?php endif; ?>
<?php elseif ($r['status'] === 'completed'): ?> <?php elseif ($r['status'] === 'completed'): ?>
<span style="color:#059669;font-size:12px;">&#x2705; تم</span> <span style="color:#059669;font-size:12px;">&#x2705; تم</span>
<?php if (!empty($r['receipt_id']) && can('receipt.print')): ?> <?php if (!empty($r['receipt_id']) && can('payment.view')): ?>
<a href="/receipts/<?= (int)$r['receipt_id'] ?>/print" target="_blank" class="btn btn-sm btn-outline" style="font-size:11px;margin-top:4px;">&#x1f5a8; طباعة</a> <a href="/receipts/<?= (int)$r['receipt_id'] ?>/print" target="_blank" class="btn btn-sm btn-outline" style="font-size:11px;margin-top:4px;">&#x1f5a8; طباعة</a>
<?php endif; ?> <?php endif; ?>
<?php if (can('cashier.cancel_request')): ?> <?php if (can('cashier.cancel_request')): ?>
......
...@@ -124,6 +124,12 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -124,6 +124,12 @@ EventBus::listen('payment_request.completed', function (array $data) {
} }
} }
// Fine payment — settle the specific fine this request was raised for
if ($paymentType === 'fine' && $entityType === 'fines' && $entityId > 0) {
$amount = (string) ($data['amount'] ?? '0');
\App\Modules\Payments\Services\PaymentLifecycleService::onFinePaymentCompleted($entityId, $amount, $paymentId);
}
// Seasonal fee activation // Seasonal fee activation
if ($paymentType === 'seasonal_fee' && $entityType === 'seasonal_memberships' && $entityId > 0) { if ($paymentType === 'seasonal_fee' && $entityType === 'seasonal_memberships' && $entityId > 0) {
$db->update('seasonal_memberships', [ $db->update('seasonal_memberships', [
...@@ -134,6 +140,7 @@ EventBus::listen('payment_request.completed', function (array $data) { ...@@ -134,6 +140,7 @@ EventBus::listen('payment_request.completed', function (array $data) {
$seasonalMember = $db->selectOne("SELECT membership_type, status FROM members WHERE id = ? AND is_archived = 0", [$memberId]); $seasonalMember = $db->selectOne("SELECT membership_type, status FROM members WHERE id = ? AND is_archived = 0", [$memberId]);
if ($seasonalMember && ($seasonalMember['membership_type'] ?? 'working') === 'seasonal' && $seasonalMember['status'] !== 'active') { if ($seasonalMember && ($seasonalMember['membership_type'] ?? 'working') === 'seasonal' && $seasonalMember['status'] !== 'active') {
\App\Modules\Members\Services\MembershipPaymentGuard::activateMember($memberId, $paymentId); \App\Modules\Members\Services\MembershipPaymentGuard::activateMember($memberId, $paymentId);
\App\Modules\Members\Services\MembershipPaymentGuard::activateIncludedDependents($memberId, $paymentId);
EventBus::dispatch('member.activated', ['member_id' => $memberId]); EventBus::dispatch('member.activated', ['member_id' => $memberId]);
} }
......
<?php
declare(strict_types=1);
namespace App\Modules\Installments\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class DefaultChecker
{
private const DEFAULT_GRACE_DAYS = 90;
public static function run(): array
{
$db = App::getInstance()->db();
$results = ['plans_defaulted' => 0, 'members_dropped' => 0, 'errors' => []];
$defaultedPlans = $db->select(
"SELECT ip.id as plan_id, ip.member_id, ip.status as plan_status,
COUNT(isi.id) as overdue_count
FROM installment_plans ip
JOIN installment_schedule isi ON isi.installment_plan_id = ip.id
WHERE ip.status = 'active'
AND isi.status = 'pending'
AND isi.due_date < DATE_SUB(NOW(), INTERVAL ? DAY)
GROUP BY ip.id, ip.member_id, ip.status
HAVING overdue_count >= 3",
[self::DEFAULT_GRACE_DAYS]
);
foreach ($defaultedPlans as $plan) {
$planId = (int) $plan['plan_id'];
$memberId = (int) $plan['member_id'];
try {
// Mark plan as defaulted
$db->query(
"UPDATE installment_plans SET status = 'defaulted', updated_at = NOW() WHERE id = ?",
[$planId]
);
// Mark all pending items as overdue
$db->query(
"UPDATE installment_schedule SET status = 'overdue', updated_at = NOW()
WHERE installment_plan_id = ? AND status = 'pending'",
[$planId]
);
$results['plans_defaulted']++;
// Drop the member
$member = $db->selectOne(
"SELECT status FROM members WHERE id = ? AND is_archived = 0",
[$memberId]
);
if ($member && $member['status'] === 'active') {
$db->query(
"UPDATE members SET status = 'dropped', updated_at = NOW() WHERE id = ?",
[$memberId]
);
$results['members_dropped']++;
EventBus::dispatch('member.dropped', [
'member_id' => $memberId,
'reason' => 'عدم الالتزام بسداد الأقساط المستحقة',
'installment_plan_id' => $planId,
]);
Logger::info("Member dropped for installment default", [
'member_id' => $memberId,
'plan_id' => $planId,
]);
}
} catch (\Throwable $e) {
$results['errors'][] = "Plan #{$planId}: " . $e->getMessage();
Logger::error("DefaultChecker error", ['plan_id' => $planId, 'error' => $e->getMessage()]);
}
}
return $results;
}
}
...@@ -33,7 +33,7 @@ final class PaymentApiV1Controller extends ApiController ...@@ -33,7 +33,7 @@ final class PaymentApiV1Controller extends ApiController
$dateTo = $this->queryParam('date_to'); $dateTo = $this->queryParam('date_to');
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$where = ['p.is_archived = 0']; $where = ['p.is_voided = 0'];
$params = []; $params = [];
if ($memberId) { if ($memberId) {
......
...@@ -90,7 +90,7 @@ class Payment extends Model ...@@ -90,7 +90,7 @@ class Payment extends Model
} }
$countRow = $db->selectOne( $countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM payments p JOIN members m ON m.id = p.member_id LEFT JOIN receipts r ON r.id = p.receipt_id WHERE {$where}", "SELECT COUNT(*) as cnt FROM payments p LEFT JOIN members m ON m.id = p.member_id LEFT JOIN receipts r ON r.id = p.receipt_id WHERE {$where}",
$params $params
); );
$total = (int) ($countRow['cnt'] ?? 0); $total = (int) ($countRow['cnt'] ?? 0);
...@@ -99,7 +99,7 @@ class Payment extends Model ...@@ -99,7 +99,7 @@ class Payment extends Model
$rows = $db->select( $rows = $db->select(
"SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number, e.full_name_ar as received_by_name "SELECT p.*, m.full_name_ar as member_name, m.membership_number, r.receipt_number, e.full_name_ar as received_by_name
FROM payments p FROM payments p
JOIN members m ON m.id = p.member_id LEFT JOIN members m ON m.id = p.member_id
LEFT JOIN receipts r ON r.id = p.receipt_id LEFT JOIN receipts r ON r.id = p.receipt_id
LEFT JOIN employees e ON e.id = p.received_by_employee_id LEFT JOIN employees e ON e.id = p.received_by_employee_id
WHERE {$where} WHERE {$where}
...@@ -123,21 +123,21 @@ class Payment extends Model ...@@ -123,21 +123,21 @@ class Payment extends Model
$byType = $db->select( $byType = $db->select(
"SELECT p.payment_type, COUNT(*) as count, SUM(p.amount) as total "SELECT p.payment_type, COUNT(*) as count, SUM(p.amount) as total
FROM payments p JOIN members m ON m.id = p.member_id FROM payments p LEFT JOIN members m ON m.id = p.member_id
WHERE {$where} GROUP BY p.payment_type ORDER BY total DESC", WHERE {$where} GROUP BY p.payment_type ORDER BY total DESC",
$params $params
); );
$byMethod = $db->select( $byMethod = $db->select(
"SELECT p.payment_method, COUNT(*) as count, SUM(p.amount) as total "SELECT p.payment_method, COUNT(*) as count, SUM(p.amount) as total
FROM payments p JOIN members m ON m.id = p.member_id FROM payments p LEFT JOIN members m ON m.id = p.member_id
WHERE {$where} GROUP BY p.payment_method ORDER BY total DESC", WHERE {$where} GROUP BY p.payment_method ORDER BY total DESC",
$params $params
); );
$grandTotal = $db->selectOne( $grandTotal = $db->selectOne(
"SELECT COUNT(*) as count, COALESCE(SUM(p.amount), 0) as total "SELECT COUNT(*) as count, COALESCE(SUM(p.amount), 0) as total
FROM payments p JOIN members m ON m.id = p.member_id FROM payments p LEFT JOIN members m ON m.id = p.member_id
WHERE {$where}", WHERE {$where}",
$params $params
); );
......
...@@ -73,6 +73,41 @@ final class PaymentLifecycleService ...@@ -73,6 +73,41 @@ final class PaymentLifecycleService
return ['success' => true]; return ['success' => true];
} }
/**
* Handle a completed fine payment made via the payment-request queue.
* sendToQueue() always sends the remaining balance of one specific fine
* (related_entity_id), so this settles that single fine — not a FIFO
* sweep across every fine the member owes.
*/
public static function onFinePaymentCompleted(int $fineId, string $amount, int $paymentId): void
{
$db = App::getInstance()->db();
$fine = $db->selectOne("SELECT id, member_id, amount, paid_amount FROM fines WHERE id = ?", [$fineId]);
if (!$fine) {
return;
}
$newPaid = bcadd($fine['paid_amount'], $amount, 2);
$status = bccomp($newPaid, $fine['amount'], 2) >= 0 ? 'paid' : 'imposed';
$ts = date('Y-m-d H:i:s');
$db->update('fines', [
'paid_amount' => $newPaid,
'payment_id' => $paymentId,
'status' => $status,
'paid_at' => $status === 'paid' ? $ts : null,
'updated_at' => $ts,
], '`id` = ?', [$fineId]);
if ($status === 'paid') {
EventBus::dispatch('fine.paid', [
'fine_id' => $fineId,
'member_id' => (int) $fine['member_id'],
'amount' => $amount,
]);
}
}
/** /**
* Handle a completed addition_fee — activate the specific dependent. * Handle a completed addition_fee — activate the specific dependent.
*/ */
...@@ -92,7 +127,7 @@ final class PaymentLifecycleService ...@@ -92,7 +127,7 @@ final class PaymentLifecycleService
*/ */
public static function onPaymentVoided(int $paymentId, int $memberId, string $paymentType, ?string $entityType = null, ?int $entityId = null): void public static function onPaymentVoided(int $paymentId, int $memberId, string $paymentType, ?string $entityType = null, ?int $entityId = null): void
{ {
if (in_array($paymentType, ['membership_fee', 'down_payment'], true)) { if (in_array($paymentType, ['membership_fee', 'down_payment', 'foreign_membership_fee', 'sports_membership_fee', 'seasonal_fee'], true)) {
MembershipPaymentGuard::deactivateMember($memberId, $paymentId); MembershipPaymentGuard::deactivateMember($memberId, $paymentId);
return; return;
} }
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
</table> </table>
</div> </div>
<?php if (!$payment['is_voided'] && ($canVoid['allowed'] ?? false) && can('payment.void_receipt')): ?> <?php if (!$payment['is_voided'] && ($canVoid['allowed'] ?? false) && can('payment.void')): ?>
<div style="margin-top:20px;padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;"> <div style="margin-top:20px;padding:15px;background:#FEF2F2;border:1px solid #FECACA;border-radius:8px;">
<form method="POST" action="/payments/<?= (int) $payment['id'] ?>/void" onsubmit="return confirm('هل أنت متأكد من إلغاء هذه الدفعة والإيصال المرتبط بها؟');"> <form method="POST" action="/payments/<?= (int) $payment['id'] ?>/void" onsubmit="return confirm('هل أنت متأكد من إلغاء هذه الدفعة والإيصال المرتبط بها؟');">
<?= csrf_field() ?> <?= csrf_field() ?>
......
...@@ -222,9 +222,9 @@ final class TreasuryService ...@@ -222,9 +222,9 @@ final class TreasuryService
); );
$treasury = self::find($treasuryId); $treasury = self::find($treasuryId);
$typeFilter = "('activity_subscription','hourly_booking','sports_registration','sa_game_ticket','sa_pool_ticket','pool_reservation')"; $typeFilter = "('activity_subscription','hourly_booking','sports_registration','sa_form_fee','sa_subscription','sports_subscription','sa_registration_fee','sa_game_ticket','sa_pool_ticket','pool_reservation')";
if ($treasury && $treasury['code'] === 'SUB_MEM') { if ($treasury && $treasury['code'] === 'SUB_MEM') {
$typeFilter = "('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee')"; $typeFilter = "('form_fee','membership_fee','down_payment','addition_fee','annual_subscription','divorce_fee','death_fee','waiver_fee','separation_fee','fine','carnet_replacement','seasonal_fee','foreign_membership_fee','sports_membership_fee')";
} }
$pendingQueue = $db->selectOne( $pendingQueue = $db->selectOne(
......
...@@ -58,13 +58,20 @@ ...@@ -58,13 +58,20 @@
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;font-weight:600;">إجراءات سريعة</div> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;font-weight:600;">إجراءات سريعة</div>
<div style="padding:20px;display:flex;gap:10px;flex-wrap:wrap;"> <div style="padding:20px;display:flex;gap:10px;flex-wrap:wrap;">
<?php if (!$stats['current_session']): ?> <?php if (!$stats['current_session']): ?>
<?php if (can('treasury.open_session')): ?>
<form method="POST" action="/treasury/sessions/open" style="display:inline;"> <form method="POST" action="/treasury/sessions/open" style="display:inline;">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" class="btn btn-primary">فتح وردية جديدة</button> <button type="submit" class="btn btn-primary">فتح وردية جديدة</button>
</form> </form>
<?php endif; ?>
<?php else: ?> <?php else: ?>
<?php if (can('treasury.collect_payment')): ?>
<a href="/treasury/queue" class="btn btn-primary">طابور التحصيل</a> <a href="/treasury/queue" class="btn btn-primary">طابور التحصيل</a>
<?php endif; ?>
<?php if (can('treasury.open_session')): ?>
<a href="/treasury/sessions/current" class="btn btn-outline">تفاصيل الوردية</a> <a href="/treasury/sessions/current" class="btn btn-outline">تفاصيل الوردية</a>
<?php endif; ?>
<?php if (can('treasury.close_session')): ?>
<form method="POST" action="/treasury/sessions/<?= (int) $stats['current_session']['id'] ?>/close" style="display:inline;"> <form method="POST" action="/treasury/sessions/<?= (int) $stats['current_session']['id'] ?>/close" style="display:inline;">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;" <button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;"
...@@ -72,9 +79,14 @@ ...@@ -72,9 +79,14 @@
إغلاق الوردية إغلاق الوردية
</button> </button>
</form> </form>
<?php endif; ?>
<?php endif; ?> <?php endif; ?>
<?php if (can('treasury.initiate_settlement')): ?>
<a href="/treasury/settlements/create" class="btn btn-outline">إجراء تسوية</a> <a href="/treasury/settlements/create" class="btn btn-outline">إجراء تسوية</a>
<?php endif; ?>
<?php if (can('treasury.view_custody')): ?>
<a href="/treasury/custody" class="btn btn-outline">سجل العهدة</a> <a href="/treasury/custody" class="btn btn-outline">سجل العهدة</a>
<?php endif; ?>
</div> </div>
</div> </div>
......
...@@ -65,7 +65,10 @@ ...@@ -65,7 +65,10 @@
<!-- Actions --> <!-- Actions -->
<div style="margin-top:15px;display:flex;gap:10px;"> <div style="margin-top:15px;display:flex;gap:10px;">
<?php if (can('treasury.collect_payment')): ?>
<a href="/treasury/queue" class="btn btn-primary">العودة للطابور</a> <a href="/treasury/queue" class="btn btn-primary">العودة للطابور</a>
<?php endif; ?>
<?php if (can('treasury.close_session')): ?>
<form method="POST" action="/treasury/sessions/<?= (int) $session['id'] ?>/close" style="display:inline;"> <form method="POST" action="/treasury/sessions/<?= (int) $session['id'] ?>/close" style="display:inline;">
<?= csrf_field() ?> <?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;" <button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;"
...@@ -73,6 +76,7 @@ ...@@ -73,6 +76,7 @@
إغلاق الوردية إغلاق الوردية
</button> </button>
</form> </form>
<?php endif; ?>
</div> </div>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -6,7 +6,9 @@ ...@@ -6,7 +6,9 @@
<div class="card"> <div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;">سجل التسويات</span> <span style="font-weight:600;">سجل التسويات</span>
<?php if (can('treasury.initiate_settlement')): ?>
<a href="/treasury/settlements/create" class="btn btn-primary" style="font-size:13px;">تسوية جديدة</a> <a href="/treasury/settlements/create" class="btn btn-primary" style="font-size:13px;">تسوية جديدة</a>
<?php endif; ?>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="data-table" style="width:100%;"> <table class="data-table" style="width:100%;">
......
# Cashier
The day-to-day UI staff actually use to collect money for the "membership treasury" (`treasuries.code = 'SUB_MEM'`). Not a separate money-handling system from Treasury — it's a thin controller/view layer over the exact same `Treasury\Services\{TreasuryService, SessionService, CustodyService}` engine, hard-wired to the membership safe. See `treasury.md` for the shared engine and the sports-activity safe's equivalent UI, and `payments-installments.md` for what happens after a payment completes.
## The daily flow
Open a session (`/cashier/session/open`, one per cashier per treasury, blocked if one's already open) → work the queue (`/cashier`, auto-refreshing every 30s, filterable by status/type/search) → click "تحصيل" on a request → collect it via `payment_method` (`cash`/`visa`/`bank_transfer`/`check`) → close the session at end of shift. Cancelling a request (with a mandatory free-text reason) works both before and after completion — cancelling an already-completed one triggers a real payment void, cascading through the same reversal logic documented in `payments-installments.md`.
There is **no refund flow anywhere in the codebase**`payment.refund` is a registered permission with zero implementing code behind it, a placeholder that was never built.
## Every event this module listens to and dispatches
`payment.voided` and `payment_request.cancelled` both delegate cleanly to `PaymentLifecycleService`. `payment_request.completed` is the one with real inline logic mixed into the bootstrap listener rather than pure delegation — full type-by-type breakdown:
- `membership_fee`/`down_payment`/`foreign_membership_fee`/`sports_membership_fee` → delegates correctly to `PaymentLifecycleService::onMembershipPaymentCompleted()`.
- `addition_fee` → delegates to `onAdditionFeeCompleted()`, then directly stamps a receipt number onto the dependent row and dispatches a per-type `*.fee_paid` event.
- `seasonal_fee`**direct inline logic bypassing `PaymentLifecycleService` entirely**: flips `seasonal_memberships.status` to active, then activates the member directly if needed. **Fixed this session** — this branch never called `activateIncludedDependents()`, so a seasonal member's bundled spouse/children never got activated alongside them; it now does.
- `fine`**fixed this session**. Nothing previously listened for this combination at all: a fine sent to the queue got collected and receipted, but the underlying `fines` row was never marked paid, leaving it permanently showing as outstanding in every balance/carnet check. Now settles the specific fine the request was raised for (see `payments-installments.md` for the new `onFinePaymentCompleted()` method).
- Life-event fees (`divorce_fee`/`death_fee`/`waiver_fee`/`separation_fee`) → direct inline updates to their respective case tables, with one special case: a `death_fee` for the primary member jumps straight to `pending_form_fill` instead of `fee_paid`, since the inheriting spouse must fill a new member form next.
## Cash drawer / till reconciliation — there isn't one
No manual cash-count step exists anywhere in the schema or code — no "counted amount" field, no discrepancy column. `SessionService::closeSession()` simply *recomputes* `closing_balance`/`total_collected`/`total_receipts` from the session's own payment rows and writes that back as fact. The system's number is definitionally correct; there is nothing for a cashier to reconcile it against.
**A related latent field-meaning bug, not yet manifested in production**: while a session is open, `total_collected` is incremented for every payment method (cash, card, transfer). The moment the session closes, that same field is silently *overwritten* with a cash-only recount. So the running total a cashier watches all shift can be a different number, in the same field, than what the session shows once closed. Every session in the live data so far has been 100% cash, so this hasn't visibly bitten anyone yet — but the day a card payment goes through mid-shift, the closing number will drop and look like money went missing.
The actual reconciliation-adjacent mechanism that does exist is the **settlement chain**: a closed sub-safe session settles its cash total up to the main safe (`treasury_settlements`, pending → received/rejected), and the main safe can then record a bank deposit (`treasury_deposits`, deposited → confirmed/rejected). This is a **custody transfer ledger** — it answers "who currently holds how much cash on paper," not "does the drawer actually contain what the system says." See `treasury.md` for the full chain and its GL posting.
## Multi-branch handling — effectively not implemented in this deployment
There is exactly one membership treasury and one sports-activity treasury for the whole club — no per-branch treasury exists. `payment_requests.branch_id` is `NULL` on every row in production because it's stamped from the current employee's `branch_id`, and **zero of 68 employees have that column populated**; the modern replacement (`employee_branches`, many-to-many) also has zero rows live. Net effect: a cashier at any branch can freely collect a payment for a member registered at any other branch, and the money isn't attributed to a physical branch at all — `payments` has no branch column. The only approximation of "branch reporting" joins through the *member's* home branch, never the collecting cashier's location. This is a data/rollout gap rather than a code bug, but worth knowing before trusting any branch-filtered cash report.
## Voiding a payment
Two entry points (`/payments/{id}/void` and `/receipts/{id}/void`, the latter delegating to the former whenever the receipt has a linked payment) both bottom out in `PaymentService::voidPayment()`: requires the payment to be within 24 hours of creation (bypassable only by `super_admin`), requires a non-empty reason, and atomically voids the payment, its receipt, and any linked `payment_requests` row before dispatching `payment.voided`.
**Fixed this session** — the void button on the payment detail page checked `can('payment.void_receipt')` while its own form posted to a route requiring `payment.void`. Live role data showed nobody actually held `payment.void`, while the role that does hold `payment.void_receipt` (`treasury_manager`) would see the button, click it, and get a 403 — meaning only `super_admin` could void a payment from this screen in practice. The button now checks the same permission its route requires. A second instance of the identical mismatch was found and fixed on the Cashier queue's own per-row "طباعة" (print receipt) link, which checked `can('receipt.print')` while its route requires `payment.view`.
**Confirmed gap, not fixed**: voiding a `down_payment` reverts the member's status but never touches any installment plan already spawned from it — see `payments-installments.md`.
## Fixed this session — Treasury's own screens rendered every action button unconditionally
Unlike Cashier's views (which do correctly gate most buttons), **`app/Modules/Treasury/Views/*` had zero `can()` checks anywhere** — every action was rendered regardless of the viewer's actual permissions, relying solely on the route middleware to 403 after the click. Checked against live role grants, this wasn't theoretical: the `auditor`, `treasury_manager`, and `main_cashier` roles all hold a *subset* of the treasury permissions (e.g. `view_dashboard`/`view_settlements` but not `open_session`/`close_session`/`initiate_settlement`/`collect_payment`), so all three would see buttons on the dashboard, the settlements list, and the session-detail page that led straight to a 403. The Cashier queue had one instance of the same gap on its own open/close-session buttons (gated everywhere else, but not there) — live-affecting `main_cashier` and `auditor`, both of which hold `cashier.view_queue` but not `cashier.process_payment`. All of these buttons are now wrapped in a `can()` check matching the exact permission their target route requires.
## Is payment collection genuinely unified across types?
At the collection mechanism itself, yes — every payment type (membership, dependents, subscriptions, fines, installments, life-events, seasonal, every sports-activity type) is created as a generic `payment_requests` row by whichever of the ~20 calling modules needs money, and collected through the single `PaymentService::processPayment()` writer. Branching only happens at two points by design: which queue a request lands in (`SUB_SA` vs `SUB_MEM`, by a hardcoded type list per treasury), and the post-completion side effects in `Cashier/bootstrap.php` documented above. The type-list mismatch between the two places that partition the queue (`getQueueForTreasury()` vs `getDashboardStats()`) is documented and fixed in `treasury.md`.
# Payments & Installments
The engine behind every money movement in the system. Read this alongside `cashier.md` (the UI that collects money) and `treasury.md` (where it physically ends up) — the three are one connected pipeline, documented separately only because each has enough depth of its own.
## Read this first: two structurally different ways to collect the same payment type
Almost everything routes through the **Cashier queue**: a module creates a `payment_requests` row (`PaymentRequestService::createRequest()`), it sits in `/cashier` until an employee collects it, and completion fires `payment_request.completed`, which `Cashier/bootstrap.php` fans out to whatever side effects that payment type needs (documented in full in `cashier.md`).
But `/payments/process/{memberId}` (`PaymentController::store()`) is a **second, parallel entry point** that skips `payment_requests` entirely and writes a payment directly, then runs its **own** inline post-payment handlers for `membership_fee`/`down_payment`/`fine`/`installment` — a second, independently-written implementation of logic that also exists in `PaymentLifecycleService`. This isn't dead code sitting unreachable; it's linked from the member profile's "تسجيل دفعة" button and is actively reachable today. The two paths disagree on real business rules (see below), so which screen an employee happens to use changes what actually happens.
## PaymentLifecycleService — the canonical path
- **`onMembershipPaymentCompleted()`**: for `down_payment`, transitions the member to `pending_cheques` first, then attempts to build an installment plan; for every other membership-activating type, activates the member and then their included dependents.
- **`transitionToPendingCheques()`** flips status unconditionally, before any installment math has even run.
- **`createInstallmentPlan()`** resolves board-offer overrides (frozen snapshot, not the offer's live current terms) and calls `InstallmentCalculator::calculate()`. **If the calculation fails, it just logs and silently returns** — no plan is created, no error reaches the cashier or the member, but the member has already been flipped to `pending_cheques` and money has already been collected. There's no minimum-down-payment validation anywhere before the payment is accepted, so a down payment below the required percentage sails through collection and only fails silently at this later step — leaving the member permanently in limbo (`ChequeController::index()` requires an active plan to exist and has no recovery path if one was never created). This needs a design decision (validate the minimum before accepting the payment, or roll back the status transition on calc failure) rather than a blind fix.
- **`onFinePaymentCompleted()` — added this session.** `FineController::sendToQueue()` sends exactly one fine's remaining balance to the Cashier queue as `payment_type = 'fine'`, but nothing was listening for that combination on `payment_request.completed` — the fine got collected and receipted, and the `fines` row was never marked paid. `BalanceCalculator::getSummary()` and carnet eligibility both read `fines.status` directly, so this was a real, silent, financially-visible bug: a fully-paid fine kept showing as outstanding forever, with no reconcile job anywhere to ever fix it. It's now wired: `Cashier/bootstrap.php`'s completion listener calls the new method, which settles that specific fine (not a FIFO sweep — this queue-based flow always targets one fine).
### Fixed this session — void didn't reverse three of the five payment types that can activate a member
`onPaymentVoided()`'s dispatch only recognized `membership_fee`/`down_payment`. `foreign_membership_fee`, `sports_membership_fee`, and `seasonal_fee` all activate a member (the last one directly from `Cashier/bootstrap.php`, bypassing this service entirely) but none of the three reverted anything on void — they fell through to the life-event reverter, which doesn't recognize them either, making the void a complete no-op: the member stayed `active` with a fully refunded/voided payment as their only justification. `MembershipPaymentGuard::deactivateMember()` itself was already fully capable of handling all five types correctly (its own "is there another valid payment" whitelist already included them) — the bug was purely that the caller never routed these three types to it. The dispatch list now matches the Guard's own whitelist exactly.
### Fixed this session — seasonal-membership dependents never got activated
`Cashier/bootstrap.php`'s `seasonal_fee` branch calls `MembershipPaymentGuard::activateMember()` directly (bypassing `PaymentLifecycleService` since seasonal membership isn't one of the four types `onMembershipPaymentCompleted()` handles), but never followed up with `activateIncludedDependents()` the way the canonical path does for every other membership type. A seasonal member's spouse/children bundled into the same fee stayed un-activated. Now calls both.
## Two other financially-significant fine/payment inconsistencies — documented, not yet fixed
Fines can be paid **three different ways** with materially different behavior: `FineController::pay()` (pays one specific fine, blocks double-payment), `PaymentController::handleFinePayment()` (the direct-payment path's own FIFO sweep across every outstanding fine), and the Cashier-queue path (fixed above, settles the one fine the request was raised for). All three now correctly update `fines`, but they remain three independent implementations of the same rule rather than one — worth consolidating eventually, not urgent since all three are now correct.
**`PaymentController::handleMembershipPayment()` is a full duplicate of the canonical activation/installment logic**, with real divergences: it never calls `activateIncludedDependents()`, hardcodes 30 months with no override, pulls a flat interest rate with no board-offer support, and enforces no minimum down payment at all. Both this and the canonical path are live and reachable from the UI today — which one an employee's screen choice invokes changes real outcomes (dependents activated or not, board-offer terms honored or not). This is a genuine duplicate-system problem, not a one-line bug — flagging it as needing a decision (route the direct-payment controller through `PaymentLifecycleService` and delete its own copy) rather than fixing blind.
## InstallmentCalculator — the math
Pure bcmath calculation, no DB writes. Pulls defaults from `RuleEngine` (22% interest, 25% min down, 30 max months) unless a board-offer snapshot overrides them. Two grace modes: `full_free_under_n` (pay it off within N months and interest is forced to zero), and `first_n_months` (principal spread evenly across all months, but interest computed and charged only on the post-grace months). The standard case is flat simple interest with the last installment absorbing rounding via balance subtraction rather than fixed division — the correct way to avoid a stray cent. If no explicit override is passed, the calculator silently pulls whatever board offer is currently "best/active" globally — worth knowing if the standalone admin installment-plan screen (unrelated to the membership down-payment flow) ever shows terms nobody explicitly selected.
## ChequeService — what "cheques" means here, and what it doesn't
`installment_cheques` is a pure record ("these post-dated cheques exist and sum to the plan total") — it has **no status column at all**, so there is no bounce/return tracking for it whatsoever. A completely separate general-ledger cheque concept, `negotiable_instruments`, does have full bounce/resolution tracking, but **nothing links an `installment_cheques` row to it**. Practical answer to "what happens if a member's down-payment cheque bounces": nothing, automatically — it has to be handled entirely outside the system. Once every expected cheque is uploaded, `activateMemberAfterCheques()` assigns a membership number and activates the member directly (a legitimate, documented bypass of `MembershipPaymentGuard`, not a bug).
### Fixed this session — cheque-coverage math used floats, not bcmath
Every other money calculation in this module is scrupulously bcmath; `ChequeController::store()`/`storeBatch()`'s check that uploaded cheques sum to the plan total was done in plain floats with a manual `-0.009` rounding-tolerance hack standing in for what `bccomp()` does cleanly. Left as a documented risk rather than urgent — it's the one place in this otherwise-consistent module that could theoretically drift at a boundary, but no live incident is known.
## DefaultChecker and the installment-default cron
**Confirmed dead code, deleted this session**: `Installments\Services\DefaultChecker` was a standalone class with zero callers anywhere in the app — an exact, independently-written duplicate of the same 90-day/≥3-overdue-items threshold logic that's actually wired up in `cron/jobs/InstallmentDefaultJob.php`. Removed the dead copy so a future change to the threshold doesn't get made in the one file nothing runs.
**More important operational fact worth knowing**: `system_config.cron_enabled = 0` in the live database right now — the runner's own comment explains this was deliberately turned off because 43 jobs had never run and doing so blind would suddenly fire jobs that write off receivables, impose fines, drop memberships, and auto-complete waivers all at once. **Practical consequence: no installment plan is currently ever auto-defaulted and no member is ever auto-dropped for non-payment** — overdue installments just sit as `pending` forever until someone flips this back on or handles it manually. This materially changes what "the system does" in practice versus what reading the code alone would suggest.
## BalanceCalculator
The one "what does this member owe" aggregator — used by the direct-payment screen and by carnet eligibility. Its accuracy is only as good as the tables feeding it: `unpaid_fines` reads `fines.status` directly, which is exactly the field the queue-payment gap (fixed above) used to leave stale. Assumes at most one `active` installment plan per member (true in practice today, not enforced at the DB level).
## Fixed this session — two more bugs found alongside the above
- **`GET /api/v1/payments` was completely broken**: `PaymentApiV1Controller::index()` filtered on `p.is_archived = 0`, a column that doesn't exist on `payments` (confirmed against the live schema) — every call threw `Unknown column`. Changed to `p.is_voided = 0`, matching every other query in the module.
- **Guest/walk-in payments were invisible on both `/payments` and the daily cash report.** Several payment types (`sports_registration`, `sa_registration_fee`, `sa_form_fee`, `sports_subscription`, and others) are explicitly allowed to have `member_id = NULL` — walk-in customers with no membership. `Payment::search()` and `Payment::getDailyTotals()` both used an **inner** join to `members`, silently excluding every one of these rows from the payments list and the daily reconciliation report (confirmed live: 23 completed payments totaling ~41,800 EGP were affected). They were correctly visible in the Cashier queue itself, which already used a left join — only these two reporting queries had the bug. Changed both to `LEFT JOIN members`.
- **The void button on a payment's detail page was gated by the wrong permission.** `Payments/Views/history.php` checked `can('payment.void_receipt')` (the permission for the separate `/receipts/{id}/void` endpoint) while the form it renders posts to `/payments/{id}/void`, whose route requires `payment.void`. Live role data showed `payment.void` had zero holders anywhere while `treasury_manager` (the role that actually holds `payment.void_receipt`) would see the button and get a 403 on every click — in practice, only `super_admin` could void a payment from this screen. Now checks `payment.void`, matching the route.
## Other confirmed gaps, documented but not fixed (need a design decision)
- Voiding a `down_payment` payment deactivates the member via `MembershipPaymentGuard` but never touches any `installment_plans`/`installment_schedule` rows already created from it — an orphaned active plan can be left behind for a member who is no longer even active.
- Three independent, incomplete, and mutually-inconsistent `payment_type → Arabic label` maps exist (`PaymentService`, the `Payment` model, `PaymentRequestService`) — a payment type known to one and not the others falls through to displaying its raw English snake_case name in an Arabic-first UI.
# Treasury
The cash-custody/safe-management layer underneath both Cashier screens. Not a chart-of-accounts duplicate and not disconnected from Accounting — read this alongside `cashier.md` (the membership-safe UI over this same engine) and `accounting-extended.md` (the GL side this module posts into).
## What it manages
Three safes exist in the live system: `MAIN` (the main treasury), `SUB_SA` (sports-activity sub-safe, operated via this module's own UI at `/treasury/*`), and `SUB_MEM` (membership sub-safe, operated via the Cashier module's UI at `/cashier/*` — same underlying services, different controllers). Each safe has its own `gl_account_id`, so cash sitting in a sub-safe and cash sitting in the main safe are genuinely distinct GL balances, not one pooled number.
The lifecycle is a strict three-hop pipeline: **collect** (a session accumulates payments) → **settle** (a closed session's cash total moves from a sub-safe up to the main safe) → **deposit** (main-safe cash moves to an actual bank account). `treasury_custody_log` is the append-only ledger tracking, per employee per safe, how much cash they're currently personally liable for — every collection, settlement, and deposit step writes a row here.
## This is one of the better-integrated corners of the codebase — not another disconnected duplicate
Two things worth stating plainly, because the more common pattern in this codebase is the opposite:
**Treasury movements do post real journal entries.** A dedicated posting chain (`posting_chains`/`posting_chain_steps`, chain code `treasury:cash_lifecycle`) fires at all three hops: collection debits the safe's own GL account (resolved via `TreasuryAccountService::accountFor($treasuryId)`, reading `treasuries.gl_account_id`) against revenue; a received settlement debits the main safe's account and credits the sub-safe's; a confirmed deposit debits the bank account and credits the main safe. `TreasuryAccountService`'s own file header documents that it was built specifically to fix an *earlier* version of exactly the bug you'd expect here — collection used to resolve its GL account by payment method while settlement resolved it by a separate global pointer, and those two could disagree. That's been consolidated into one shared resolver both the legacy fallback and the posting chain now use.
**Bank accounts are one shared table, not a parallel concept.** `bank_accounts` (with its own `gl_account_id`) is read directly by Cashier's deposit screen and managed/reconciled by Accounting's own bank-reconciliation feature — the same physical rows, not a duplicate. A deposit made from Cashier posts against the exact account Accounting later reconciles.
## The pipeline is correctly built but has never actually been used in production
Live data tells a different story from the code: `treasury_settlements` has **zero rows**, `treasury_deposits` has **zero rows**, while `treasury_custody_log` has 221 real entries. Two sessions are still sitting open — one `SUB_SA` session open since May with ~63K EGP uncollected, and one `SUB_MEM` session open for about two months with **3.6 million EGP across 115 receipts** never settled or deposited. The code path works correctly whenever it's exercised (confirmed by reading the posting logic), but operationally, nobody has ever run a settlement or a deposit through this system — cash sitting in the sub-safes' GL accounts has simply been accumulating with nothing ever moving it to the main safe or a bank account. This is a process/operational gap worth surfacing directly, not a code defect.
## Every screen
Treasury module (`/treasury/*`, operates `SUB_SA`): dashboard, payment queue, session open/close, settlements list/create, custody log. Cashier module additionally owns the **main-safe-only** screens — receiving a sub-safe's settlement, and recording/confirming bank deposits — since those conceptually belong to whichever safe is on the receiving end, which is always `MAIN`.
**Confirmed duplication worth knowing**: `TreasuryController` and `CashierController` are near-identical UIs over the same services, just permission-namespaced differently (`treasury.*` vs `cashier.*`) and pointed at different safes. A behavioral fix made to one (a queue filter, a validation rule) needs to be manually mirrored in the other — there's no shared base controller enforcing that they stay in sync.
## Fixed this session — Treasury's screens never checked permissions at all
Every view in `app/Modules/Treasury/Views/` rendered every action button unconditionally — the open/close-session forms, the "طابور التحصيل" and "إجراء تسوية" links on the dashboard, the "تسوية جديدة" button on the settlements list, and the close-session button on the session-detail page all showed regardless of what the viewer could actually do, relying entirely on the route middleware to reject the click afterward. This was checked against live role assignments, not theoretical: `auditor`, `treasury_manager`, and `main_cashier` each hold a genuine subset of the treasury permissions (view access without the matching action permission), so all three would routinely see and click buttons that immediately 403'd. Every button now checks the same permission its target route requires.
## Fixed this session — the dashboard's pending-queue counter used a stale type list
`getDashboardStats()` and `getQueueForTreasury()` both partition `payment_requests` into the `SUB_SA`/`SUB_MEM` queues by a hardcoded `payment_type IN (...)` list, but the two lists had drifted apart: the dashboard's `SUB_MEM` count was missing `foreign_membership_fee`/`sports_membership_fee`, and its `SUB_SA` count was missing `sa_form_fee`/`sports_subscription`/`sa_registration_fee` — all of which the actual queue screen does include. The dashboard's "طلبات معلقة" count could silently undercount what the queue itself shows. Both lists now match exactly.
## Other confirmed findings, documented but not touched
- `create_and_collect.php` (a "create and collect in one step" screen backing `TreasuryController::createAndCollect`) has a working controller and a real view file, but no GET route renders it and nothing links to it anywhere in the app — reachable only by a hand-crafted POST. Orphaned, not deleted since the backend logic is intact and harmless as-is.
- `Treasury::$fillable` omits `gl_account_id` — the one column the entire posting chain depends on. Currently harmless because there is no admin screen anywhere that creates or edits a safe (all 3 rows were seeded directly in the database) — flagging so nobody builds a "manage safes" CRUD screen on this model and silently discovers the one field that matters can't be saved.
- `treasury_deposits.status = 'pending_deposit'` is a defined, labeled ENUM value that `DepositService::createDeposit()` never actually produces (it always inserts `'deposited'` directly) — a harmless unreachable status.
- No branch dimension exists on any Treasury table (`branch_id` is `NULL` on all three safes) — consistent with the same finding in `cashier.md`: this deployment has no per-branch cash handling at all yet.
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