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,17 +15,20 @@
<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>
</div>
<?php if (can('cashier.process_payment')): ?>
<form method="POST" action="/cashier/session/close" style="margin:0;" onsubmit="return confirm('هل تريد إغلاق الوردية الحالية؟');">
<?= csrf_field() ?>
<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> إغلاق الوردية
</button>
</form>
<?php endif; ?>
<?php else: ?>
<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="font-size:13px;color:#6B7280;">يجب فتح وردية قبل التحصيل</span>
</div>
<?php if (can('cashier.process_payment')): ?>
<form method="POST" action="/cashier/session/open" style="margin:0;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="background:#059669;color:#fff;font-size:12px;">
......@@ -33,6 +36,7 @@
</button>
</form>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
......@@ -212,7 +216,7 @@
<?php endif; ?>
<?php elseif ($r['status'] === 'completed'): ?>
<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>
<?php endif; ?>
<?php if (can('cashier.cancel_request')): ?>
......
......@@ -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
if ($paymentType === 'seasonal_fee' && $entityType === 'seasonal_memberships' && $entityId > 0) {
$db->update('seasonal_memberships', [
......@@ -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]);
if ($seasonalMember && ($seasonalMember['membership_type'] ?? 'working') === 'seasonal' && $seasonalMember['status'] !== 'active') {
\App\Modules\Members\Services\MembershipPaymentGuard::activateMember($memberId, $paymentId);
\App\Modules\Members\Services\MembershipPaymentGuard::activateIncludedDependents($memberId, $paymentId);
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
$dateTo = $this->queryParam('date_to');
$db = App::getInstance()->db();
$where = ['p.is_archived = 0'];
$where = ['p.is_voided = 0'];
$params = [];
if ($memberId) {
......
......@@ -90,7 +90,7 @@ class Payment extends Model
}
$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
);
$total = (int) ($countRow['cnt'] ?? 0);
......@@ -99,7 +99,7 @@ class Payment extends Model
$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
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 employees e ON e.id = p.received_by_employee_id
WHERE {$where}
......@@ -123,21 +123,21 @@ class Payment extends Model
$byType = $db->select(
"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",
$params
);
$byMethod = $db->select(
"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",
$params
);
$grandTotal = $db->selectOne(
"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}",
$params
);
......
......@@ -73,6 +73,41 @@ final class PaymentLifecycleService
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.
*/
......@@ -92,7 +127,7 @@ final class PaymentLifecycleService
*/
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);
return;
}
......
......@@ -35,7 +35,7 @@
</table>
</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;">
<form method="POST" action="/payments/<?= (int) $payment['id'] ?>/void" onsubmit="return confirm('هل أنت متأكد من إلغاء هذه الدفعة والإيصال المرتبط بها؟');">
<?= csrf_field() ?>
......
......@@ -222,9 +222,9 @@ final class TreasuryService
);
$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') {
$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(
......
......@@ -58,13 +58,20 @@
<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;">
<?php if (!$stats['current_session']): ?>
<?php if (can('treasury.open_session')): ?>
<form method="POST" action="/treasury/sessions/open" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary">فتح وردية جديدة</button>
</form>
<?php endif; ?>
<?php else: ?>
<?php if (can('treasury.collect_payment')): ?>
<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>
<?php endif; ?>
<?php if (can('treasury.close_session')): ?>
<form method="POST" action="/treasury/sessions/<?= (int) $stats['current_session']['id'] ?>/close" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;"
......@@ -73,8 +80,13 @@
</button>
</form>
<?php endif; ?>
<?php endif; ?>
<?php if (can('treasury.initiate_settlement')): ?>
<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>
<?php endif; ?>
</div>
</div>
......
......@@ -65,7 +65,10 @@
<!-- Actions -->
<div style="margin-top:15px;display:flex;gap:10px;">
<?php if (can('treasury.collect_payment')): ?>
<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;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;border-color:#DC2626;"
......@@ -73,6 +76,7 @@
إغلاق الوردية
</button>
</form>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
......@@ -6,7 +6,9 @@
<div class="card">
<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>
<?php if (can('treasury.initiate_settlement')): ?>
<a href="/treasury/settlements/create" class="btn btn-primary" style="font-size:13px;">تسوية جديدة</a>
<?php endif; ?>
</div>
<div class="table-responsive">
<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`.
This diff is collapsed.
# 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