Commit 9bb0c943 authored by DevPilot's avatar DevPilot

feat(accounting): full bounced-cheque cycle, and the cheques the bank went quiet on

A bounce was one status change and one entry. It is actually four separate
facts, and collapsing them is where the books go wrong.

  1. The debt comes back. A cheque was never money, it was a promise; when it
     fails the drawer owes again. This part already worked.
  2. The BANK charges the club. That charge leaves the club's account whoever
     ends up bearing it, so it posts Dr مصروفات بنكية / Cr البنك the moment it
     happens. Nothing recorded it before — which means the bank reconciliation
     could never have tied out on any month with a bounce in it.
  3. Somebody bears that charge. Billing the drawer is a separate claim posted
     separately, so waiving it later does not touch the original debt. Saying
     the club bears it while also billing the drawer is now refused: it is a
     contradiction that quietly inflates income.
  4. It has to end. Collected, replaced, re-presented and cleared, sent to
     legal, or written off. A bounce with no ending is a receivable nobody is
     chasing. Only the write-off posts here (Dr ديون معدومة / Cr شيكات مرتدة,
     for the cheque plus any fees billed on top, because both are being given
     up); the others are closed by events that already post on their own, and
     posting again would double them.

The register now remembers what a bounce actually needs: the bank's reason code,
how many times the cheque has been presented, how many times it came back, what
the bank took, what was billed, who bore it, the protest number, and how it
ended. Reasons that carry criminal liability in Egypt — insufficient funds, a
closed account, a stop-payment on a valid cheque — are flagged, because the
club's response differs even though the entry does not.

The other half is delayed collection: a cheque past its due date that has NOT
bounced. The bank has said nothing, so there is no accounting event and the
screen posts nothing — but it is money the club is counting on and has not got,
split by whether it never went to the bank or went and never came back.

Also fixed: presentation_count only counted retries, so a cheque presented once
and returned read as never presented. It now increments on every trip to the
bank, in the transition itself rather than in the retry path.

Verified on a production clone through the real EventBus: a 50,000 cheque
deposited, bounced with a 75 bank charge and 100 billed to the drawer, produced
four correct entries; re-presented and bounced again, totals accumulated to 150
and 200; written off for 50,200; and every guard fired — bouncing something not
under collection, an unknown reason code, a negative charge, club-bears-plus-bill,
resolving twice, and re-presenting after resolution. Trial balance diff 0.00.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 1d1ec8ae
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\BouncedChequeService;
/**
* الشيكات المرتدة والمتأخرة — the two things that go wrong with a cheque, on one
* screen: it came back, or it never came back at all.
*/
class BouncedChequeController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.instruments.view');
$graceDays = max(0, (int) $request->get('grace', 0));
return $this->view('Accounting.Views.instruments.bounced', [
'summary' => BouncedChequeService::summary(),
'bounced' => BouncedChequeService::openBounced(),
'overdue' => BouncedChequeService::overdue($graceDays),
'grace' => $graceDays,
'reasons' => BouncedChequeService::REASONS,
'resolutions' => BouncedChequeService::RESOLUTIONS,
'banks' => App::getInstance()->db()->select(
"SELECT id, account_name_ar FROM bank_accounts WHERE is_active = 1 AND is_archived = 0 ORDER BY is_default DESC, id"
),
]);
}
/** Record that the bank returned the cheque. */
public function bounce(Request $request, string $id): Response
{
$this->authorize('accounting.instruments.manage');
$result = BouncedChequeService::bounce((int) $id, [
'reason_code' => $request->post('reason_code'),
'reason' => $request->post('reason'),
'date' => $request->post('date'),
'bank_charge' => $request->post('bank_charge'),
'fee' => $request->post('fee'),
'fee_bearer' => $request->post('fee_bearer'),
'protest_number' => $request->post('protest_number'),
'protest_date' => $request->post('protest_date'),
'notes' => $request->post('notes'),
]);
if (empty($result['success'])) {
return $this->redirect('/accounting/instruments/' . (int) $id)->withError($result['error']);
}
$response = $this->redirect('/accounting/instruments/' . (int) $id)
->withSuccess('اتسجّل ارتداد الشيك واتعملت القيود. الدين رجع على الساحب.');
return !empty($result['warning'])
? $response->withWarning('تنبيه ترحيل: ' . $result['warning'])
: $response;
}
/** Send it to the bank again. */
public function represent(Request $request, string $id): Response
{
$this->authorize('accounting.instruments.manage');
$result = BouncedChequeService::represent((int) $id, [
'date' => $request->post('date'),
'bank_account_id' => $request->post('bank_account_id'),
'notes' => $request->post('notes'),
]);
if (empty($result['success'])) {
return $this->redirect('/accounting/instruments/bounced')->withError($result['error']);
}
return $this->redirect('/accounting/instruments/bounced')
->withSuccess('اتقدّم الشيك للبنك تاني — رجع تحت التحصيل.');
}
/** Close it: paid, replaced, legal, or written off. */
public function resolve(Request $request, string $id): Response
{
$this->authorize('accounting.instruments.manage');
$result = BouncedChequeService::resolve(
(int) $id,
(string) $request->post('resolution', ''),
[
'date' => $request->post('date'),
'notes' => $request->post('notes'),
'replacement_instrument_id' => $request->post('replacement_instrument_id'),
]
);
if (empty($result['success'])) {
return $this->redirect('/accounting/instruments/bounced')->withError($result['error']);
}
return $this->redirect('/accounting/instruments/bounced')
->withSuccess('اتقفل موضوع الشيك واتسجّل السبب.');
}
}
......@@ -74,6 +74,11 @@ return [
['GET', '/accounting/instruments', 'Accounting\Controllers\NegotiableInstrumentController@index', ['auth'], 'accounting.instruments.view'],
['POST', '/accounting/instruments', 'Accounting\Controllers\NegotiableInstrumentController@store', ['auth', 'csrf'], 'accounting.instruments.manage'],
['GET', '/accounting/instruments/due-soon', 'Accounting\Controllers\NegotiableInstrumentController@dueSoon', ['auth'], 'accounting.instruments.view'],
// الشيكات المرتدة والمتأخرة — declared before {id} so the word is not read as an id
['GET', '/accounting/instruments/bounced', 'Accounting\Controllers\BouncedChequeController@index', ['auth'], 'accounting.instruments.view'],
['POST', '/accounting/instruments/{id:\d+}/bounce', 'Accounting\Controllers\BouncedChequeController@bounce', ['auth', 'csrf'], 'accounting.instruments.manage'],
['POST', '/accounting/instruments/{id:\d+}/represent', 'Accounting\Controllers\BouncedChequeController@represent', ['auth', 'csrf'], 'accounting.instruments.manage'],
['POST', '/accounting/instruments/{id:\d+}/resolve', 'Accounting\Controllers\BouncedChequeController@resolve', ['auth', 'csrf'], 'accounting.instruments.manage'],
['GET', '/accounting/instruments/{id:\d+}', 'Accounting\Controllers\NegotiableInstrumentController@show', ['auth'], 'accounting.instruments.view'],
['POST', '/accounting/instruments/{id:\d+}/change-status', 'Accounting\Controllers\NegotiableInstrumentController@changeStatus', ['auth', 'csrf'], 'accounting.instruments.manage'],
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
/**
* The full life of a cheque that did not clear.
*
* A bounce is four separate facts, and treating them as one is where the books
* go wrong:
*
* 1. **The debt comes back.** The cheque was never money — it was a promise.
* When it fails, the drawer owes again. (Dr مدينون / Cr شيكات تحت التحصيل —
* posted by InstrumentPostingService.)
* 2. **The bank charges the club.** That charge hits the club's account
* whatever anybody decides afterwards, so it is an expense and a credit to
* the bank the moment it happens. Miss it and the bank reconciliation can
* never tie out.
* 3. **Somebody bears that cost.** If the drawer bears it, the club re-bills
* him — a separate claim, posted separately, so waiving it later does not
* touch the original debt. If the club bears it, there is nothing to bill.
* 4. **It has to end.** Collected in cash, replaced by another cheque,
* re-presented and cleared, sent to legal, or written off. A bounce with no
* ending is a receivable nobody is chasing.
*
* Delayed collection is the other half. A cheque past its due date that has not
* cleared has not bounced — the bank has said nothing. But it is money the club
* is counting on and has not received, and it needs to be visible before it
* becomes a surprise.
*/
final class BouncedChequeService
{
private const SCALE = 2;
/**
* Standard bank return reasons.
*
* `criminal` marks the ones that in Egypt expose the drawer to criminal
* liability rather than just a civil debt — insufficient funds, a closed
* account, an instruction to stop payment on a valid cheque. The screen
* flags them because the club's response differs, not because the ledger
* entry does.
*/
public const REASONS = [
'insufficient_funds' => ['ar' => 'رصيد غير كافٍ', 'criminal' => true],
'account_closed' => ['ar' => 'الحساب مقفول', 'criminal' => true],
'stop_payment' => ['ar' => 'إيقاف صرف من الساحب', 'criminal' => true],
'signature_mismatch' => ['ar' => 'التوقيع غير مطابق', 'criminal' => false],
'amount_mismatch' => ['ar' => 'اختلاف المبلغ رقمًا وكتابة', 'criminal' => false],
'date_invalid' => ['ar' => 'تاريخ غير صحيح أو شيك قديم', 'criminal' => false],
'endorsement_issue' => ['ar' => 'خلل في التظهير', 'criminal' => false],
'technical' => ['ar' => 'سبب فني / بيانات ناقصة', 'criminal' => false],
'other' => ['ar' => 'سبب آخر', 'criminal' => false],
];
public const RESOLUTIONS = [
'represented_collected' => 'اتقدّم تاني واتحصّل',
'cash_settled' => 'الساحب سدّد نقدًا',
'replaced' => 'اتبدل بشيك جديد',
'legal' => 'اتحوّل للشؤون القانونية',
'written_off' => 'اتشطب كدين معدوم',
];
// ────────────────────────────────────────────────────────────────────
// Recording a bounce
// ────────────────────────────────────────────────────────────────────
/**
* @param array $opts reason_code, reason, date, bank_charge, fee, fee_bearer,
* protest_number, protest_date, notes
* @return array{success:bool, error?:string, warning?:?string}
*/
public static function bounce(int $instrumentId, array $opts): array
{
$db = App::getInstance()->db();
$instrument = $db->selectOne("SELECT * FROM negotiable_instruments WHERE id = ?", [$instrumentId]);
if (!$instrument) {
return ['success' => false, 'error' => 'الورقة غير موجودة'];
}
if ((string) $instrument['status'] !== 'under_collection') {
return ['success' => false, 'error' => 'الشيك لازم يكون تحت التحصيل عشان يترجّع — حالته دلوقتي: ' . $instrument['status']];
}
$code = (string) ($opts['reason_code'] ?? 'other');
if (!isset(self::REASONS[$code])) {
return ['success' => false, 'error' => 'سبب الارتداد غير معروف'];
}
$date = self::validDate($opts['date'] ?? null) ?? date('Y-m-d');
$bankCharge = self::money($opts['bank_charge'] ?? '0');
$fee = self::money($opts['fee'] ?? '0');
$bearer = ((string) ($opts['fee_bearer'] ?? 'drawer')) === 'club' ? 'club' : 'drawer';
if (bccomp($bankCharge, '0.00', self::SCALE) < 0 || bccomp($fee, '0.00', self::SCALE) < 0) {
return ['success' => false, 'error' => 'المبالغ لا يمكن أن تكون بالسالب'];
}
// Billing the drawer while saying the club bears it is a contradiction,
// and it is the kind that quietly inflates income.
if ($bearer === 'club' && bccomp($fee, '0.00', self::SCALE) > 0) {
return ['success' => false, 'error' => 'لو النادي هو اللي هيتحمّل المصاريف، مينفعش تحمّل الساحب رسوم — سيبها صفر'];
}
$reasonText = trim((string) ($opts['reason'] ?? '')) ?: self::REASONS[$code]['ar'];
// The status change and the debt-return entry go through the existing
// lifecycle, so the transition rules and the status history stay in one
// place. The bounce fee rides along with it.
$result = CheckLifecycleService::transition($instrumentId, 'bounced', [
'date' => $date,
'bounce_reason' => $reasonText,
'notes' => $opts['notes'] ?? null,
'bounce_fee' => $bearer === 'drawer' ? $fee : null,
]);
if (empty($result['success'])) {
return $result;
}
$db->update('negotiable_instruments', [
'bounce_code' => $code,
'bounce_count' => (int) $instrument['bounce_count'] + 1,
'bank_charge' => bcadd((string) $instrument['bank_charge'], $bankCharge, self::SCALE),
'fee_charged' => bcadd((string) $instrument['fee_charged'], $fee, self::SCALE),
'fee_bearer' => $bearer,
'protest_number' => $opts['protest_number'] ?? $instrument['protest_number'],
'protest_date' => self::validDate($opts['protest_date'] ?? null) ?? $instrument['protest_date'],
'resolution' => null, // a fresh bounce reopens the matter
'resolved_date' => null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$instrumentId]);
// The bank's charge on the club. Posted whoever ends up bearing it —
// the money left the club's account either way.
if (bccomp($bankCharge, '0.00', self::SCALE) > 0) {
self::postBankCharge($instrument, $bankCharge, $date);
}
EventBus::dispatch('instrument.bounced', [
'instrument_id' => $instrumentId,
'reason_code' => $code,
'bank_charge' => $bankCharge,
'fee' => $fee,
]);
return ['success' => true, 'warning' => $result['posting_warning'] ?? null];
}
/**
* Present the cheque to the bank again.
*
* Each presentation is its own attempt with its own outcome. The count is
* what tells you whether this is a hiccup or a pattern.
*/
public static function represent(int $instrumentId, array $opts = []): array
{
$db = App::getInstance()->db();
$instrument = $db->selectOne("SELECT * FROM negotiable_instruments WHERE id = ?", [$instrumentId]);
if (!$instrument) {
return ['success' => false, 'error' => 'الورقة غير موجودة'];
}
if ((string) $instrument['status'] !== 'bounced') {
return ['success' => false, 'error' => 'الشيك مش مرتد — مفيش حاجة تتقدّم تاني'];
}
if (!empty($instrument['resolution'])) {
return ['success' => false, 'error' => 'الشيك ده اتقفل بالفعل (' . (self::RESOLUTIONS[$instrument['resolution']] ?? $instrument['resolution']) . ')'];
}
$date = self::validDate($opts['date'] ?? null) ?? date('Y-m-d');
$result = CheckLifecycleService::transition($instrumentId, 'under_collection', [
'date' => $date,
'bank_account_id' => $opts['bank_account_id'] ?? $instrument['bank_account_id'],
'notes' => 'إعادة تقديم رقم ' . ((int) $instrument['presentation_count'] + 1)
. (!empty($opts['notes']) ? ' — ' . $opts['notes'] : ''),
]);
if (empty($result['success'])) {
return $result;
}
// presentation_count is bumped by the transition itself — every trip to
// the bank counts, not just the retries. Incrementing again here would
// double-count re-presentations.
return ['success' => true, 'warning' => $result['posting_warning'] ?? null];
}
// ────────────────────────────────────────────────────────────────────
// Ending it
// ────────────────────────────────────────────────────────────────────
/**
* Close a bounced cheque.
*
* Only `written_off` posts here — the debt is given up, so it leaves the
* receivable and lands in bad debts. The others are closed by an event that
* already posts on its own: a cash settlement is a normal collection, a
* replacement cheque is a new instrument with its own entries, and a
* successful re-presentation posted when it cleared. Posting again here
* would double every one of them.
*/
public static function resolve(int $instrumentId, string $resolution, array $opts = []): array
{
$db = App::getInstance()->db();
if (!isset(self::RESOLUTIONS[$resolution])) {
return ['success' => false, 'error' => 'طريقة إقفال غير معروفة'];
}
$instrument = $db->selectOne("SELECT * FROM negotiable_instruments WHERE id = ?", [$instrumentId]);
if (!$instrument) {
return ['success' => false, 'error' => 'الورقة غير موجودة'];
}
if ((int) $instrument['bounce_count'] === 0) {
return ['success' => false, 'error' => 'الشيك ده ما ارتدّش أصلًا'];
}
if (!empty($instrument['resolution'])) {
return ['success' => false, 'error' => 'الشيك ده مقفول بالفعل'];
}
$notes = trim((string) ($opts['notes'] ?? ''));
if ($notes === '') {
return ['success' => false, 'error' => 'اكتب سبب/تفاصيل الإقفال — بيفضل في السجل'];
}
$date = self::validDate($opts['date'] ?? null) ?? date('Y-m-d');
if ($resolution === 'written_off') {
$posted = self::postWriteOff($instrument, $date, $notes);
if (!$posted['ok']) {
return ['success' => false, 'error' => $posted['error']];
}
}
$db->update('negotiable_instruments', [
'resolution' => $resolution,
'resolved_date' => $date,
'resolution_notes' => mb_substr($notes, 0, 500),
'replacement_instrument_id' => $resolution === 'replaced'
? (((int) ($opts['replacement_instrument_id'] ?? 0)) ?: null)
: null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$instrumentId]);
EventBus::dispatch('instrument.bounce_resolved', [
'instrument_id' => $instrumentId,
'resolution' => $resolution,
]);
return ['success' => true];
}
// ────────────────────────────────────────────────────────────────────
// Reading the position
// ────────────────────────────────────────────────────────────────────
/** Bounced cheques that nobody has closed yet. */
public static function openBounced(): array
{
return App::getInstance()->db()->select(
"SELECT i.*, m.full_name_ar AS member_name, m.membership_number,
DATEDIFF(CURDATE(), i.bounced_date) AS days_since_bounce
FROM negotiable_instruments i
LEFT JOIN members m ON m.id = i.member_id
WHERE i.direction = 'receivable'
AND i.status = 'bounced'
AND i.resolution IS NULL
AND i.is_archived = 0
ORDER BY i.bounced_date ASC, i.amount DESC"
);
}
/**
* Cheques the bank has gone quiet on.
*
* Not bounced — the bank has said nothing at all. Either it is still sitting
* in the safe past its due date, or it went for collection and never came
* back. Both are money the club is counting on and has not got, and both
* need chasing before they turn into a surprise.
*/
public static function overdue(int $graceDays = 0): array
{
return App::getInstance()->db()->select(
"SELECT i.*, m.full_name_ar AS member_name, m.membership_number,
DATEDIFF(CURDATE(), i.due_date) AS days_overdue,
CASE WHEN i.status = 'in_hand' THEN 'ما اتقدّمش للبنك'
ELSE 'اتقدّم وما ردّش' END AS delay_kind
FROM negotiable_instruments i
LEFT JOIN members m ON m.id = i.member_id
WHERE i.direction = 'receivable'
AND i.status IN ('in_hand', 'under_collection')
AND i.due_date < DATE_SUB(CURDATE(), INTERVAL ? DAY)
AND i.is_archived = 0
ORDER BY i.due_date ASC",
[max(0, $graceDays)]
);
}
/** Headline numbers for the screen. */
public static function summary(): array
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT
COUNT(CASE WHEN status = 'bounced' AND resolution IS NULL THEN 1 END) AS open_count,
ROUND(COALESCE(SUM(CASE WHEN status = 'bounced' AND resolution IS NULL THEN amount END), 0), 2) AS open_amount,
COUNT(CASE WHEN status IN ('in_hand','under_collection') AND due_date < CURDATE() THEN 1 END) AS overdue_count,
ROUND(COALESCE(SUM(CASE WHEN status IN ('in_hand','under_collection') AND due_date < CURDATE() THEN amount END), 0), 2) AS overdue_amount,
COUNT(CASE WHEN bounce_count >= 2 AND resolution IS NULL THEN 1 END) AS repeat_count,
ROUND(COALESCE(SUM(bank_charge), 0), 2) AS total_bank_charges,
ROUND(COALESCE(SUM(fee_charged), 0), 2) AS total_fees_billed
FROM negotiable_instruments
WHERE direction = 'receivable' AND is_archived = 0"
);
return $row ?: [];
}
/** Every attempt on one cheque, newest first — the paper trail. */
public static function history(int $instrumentId): array
{
return App::getInstance()->db()->select(
"SELECT h.*, e.full_name_ar AS by_name
FROM instrument_status_history h
LEFT JOIN employees e ON e.id = h.created_by
WHERE h.instrument_id = ?
ORDER BY h.created_at DESC, h.id DESC",
[$instrumentId]
);
}
// ────────────────────────────────────────────────────────────────────
/**
* The bank's charge on the club: Dr مصروفات بنكية / Cr البنك.
*
* Separate from anything billed to the drawer. The club paid this whether or
* not it ever recovers it, and the bank statement will show it.
*/
private static function postBankCharge(array $instrument, string $amount, string $date): void
{
$expense = PostingRouter::accountFor('instrument:bounce_bank_charge', AccountCodes::BANK_CHARGES, 'payment');
$bank = self::bankAccountFor($instrument);
if ($expense === null || $bank === null || $expense === $bank) {
Logger::error('Bounce bank charge not posted — accounts unresolved', [
'instrument' => $instrument['id'] ?? null,
]);
return;
}
$number = $instrument['instrument_number'] ?? ('#' . ($instrument['id'] ?? ''));
$desc = 'مصاريف بنك على ارتداد شيك رقم ' . $number;
$result = JournalService::createEntry([
'entry_date' => $date,
'description_ar' => $desc,
'reference_type' => 'instrument_bank_charge',
'reference_id' => (int) ($instrument['id'] ?? 0),
'reference_number' => (string) $number,
'source_module' => 'accounting',
'is_auto_generated' => 1,
], [
['account_id' => $expense, 'debit' => $amount, 'credit' => '0.00', 'description_ar' => $desc],
['account_id' => $bank, 'debit' => '0.00', 'credit' => $amount, 'description_ar' => $desc],
], true);
if (empty($result['success'])) {
Logger::error('Bounce bank charge entry failed', [
'instrument' => $instrument['id'] ?? null,
'error' => $result['error'] ?? null,
]);
}
}
/** Writing the debt off: Dr ديون معدومة / Cr شيكات مرتدة. */
private static function postWriteOff(array $instrument, string $date, string $reason): array
{
$badDebt = PostingRouter::accountFor('instrument:bounce_write_off', '3328', 'writeoff');
$receivable = PostingRouter::accountFor('instrument:bounced_receivable', '120301005', 'accrual');
if ($badDebt === null || $receivable === null || $badDebt === $receivable) {
return ['ok' => false, 'error' => 'حساب الديون المعدومة أو الشيكات المرتدة غير محدد — اربطهم من شاشة توزيع الإيرادات'];
}
// The written-off amount is the cheque plus anything re-billed to the
// drawer on top of it — that claim is being given up as well.
$amount = bcadd(
self::money((string) $instrument['amount']),
self::money((string) ($instrument['fee_charged'] ?? '0')),
self::SCALE
);
$number = $instrument['instrument_number'] ?? ('#' . ($instrument['id'] ?? ''));
$desc = 'إعدام دين شيك مرتد رقم ' . $number . ' — ' . mb_substr($reason, 0, 120);
$result = JournalService::createEntry([
'entry_date' => $date,
'description_ar' => $desc,
'reference_type' => 'instrument_write_off',
'reference_id' => (int) ($instrument['id'] ?? 0),
'reference_number' => (string) $number,
'source_module' => 'accounting',
'is_auto_generated' => 0,
'notes' => $reason,
], [
[
'account_id' => $badDebt,
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $desc,
],
[
'account_id' => $receivable,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => $desc,
'member_id' => !empty($instrument['member_id']) ? (int) $instrument['member_id'] : null,
],
], true);
if (empty($result['success'])) {
return ['ok' => false, 'error' => $result['error'] ?? 'فشل قيد الإعدام'];
}
return ['ok' => true];
}
/** The GL account behind the instrument's bank, or the club's default. */
private static function bankAccountFor(array $instrument): ?int
{
$db = App::getInstance()->db();
if (!empty($instrument['bank_account_id'])) {
$row = $db->selectOne(
"SELECT gl_account_id FROM bank_accounts WHERE id = ?",
[(int) $instrument['bank_account_id']]
);
if ($row && !empty($row['gl_account_id'])) {
return (int) $row['gl_account_id'];
}
}
return PostingRouter::accountFor('treasury:method_bank_transfer', AccountCodes::CASH_AT_BANK, 'collection');
}
private static function validDate(?string $d): ?string
{
$d = trim((string) $d);
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) ? $d : null;
}
private static function money(mixed $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
......@@ -56,6 +56,11 @@ final class CheckLifecycleService
if (!empty($options['bank_account_id'])) {
$updateData['bank_account_id'] = (int) $options['bank_account_id'];
}
// Every trip to the bank is a presentation — the first one as
// much as the re-presentations after a bounce. Counting only
// the retries made a cheque that was presented once and
// returned read as never presented at all.
$updateData['presentation_count'] = (int) ($instrument['presentation_count'] ?? 0) + 1;
break;
case 'collected':
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الشيكات المرتدة والمتأخرة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/instruments" class="btn btn-outline">كل الأوراق التجارية</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<h2 style="margin:6px 0 4px;">الشيكات المرتدة والمتأخرة</h2>
<p style="margin:0;color:#6B7280;font-size:13px;line-height:1.9;max-width:900px;">
فيه حاجتين بيحصلوا للشيك: <strong>يرتد</strong> — البنك رفضه وردّه؛ أو
<strong>يتأخر</strong> — البنك ما قالش حاجة خالص وتاريخ الاستحقاق عدّى.
الاتنين فلوس النادي معتمد عليها وما دخلتش، بس كل واحد له إجراء مختلف.
</p>
</div>
<!-- ── الأرقام ────────────────────────────────────────────── -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px;">
<div class="card" style="padding:14px 16px;border-right:3px solid #DC2626;">
<div style="color:#6B7280;font-size:12px;">مرتدة ومفتوحة</div>
<div style="font-size:20px;font-weight:700;"><?= number_format((int) ($summary['open_count'] ?? 0)) ?></div>
<div style="color:#991B1B;font-size:12.5px;"><?= money((string) ($summary['open_amount'] ?? '0')) ?></div>
</div>
<div class="card" style="padding:14px 16px;border-right:3px solid #D97706;">
<div style="color:#6B7280;font-size:12px;">متأخرة التحصيل</div>
<div style="font-size:20px;font-weight:700;"><?= number_format((int) ($summary['overdue_count'] ?? 0)) ?></div>
<div style="color:#92400E;font-size:12.5px;"><?= money((string) ($summary['overdue_amount'] ?? '0')) ?></div>
</div>
<div class="card" style="padding:14px 16px;border-right:3px solid #7C3AED;">
<div style="color:#6B7280;font-size:12px;">ارتدّت أكتر من مرة</div>
<div style="font-size:20px;font-weight:700;"><?= number_format((int) ($summary['repeat_count'] ?? 0)) ?></div>
<div style="color:#6B7280;font-size:12px;">محتاجة قرار</div>
</div>
<div class="card" style="padding:14px 16px;border-right:3px solid #059669;">
<div style="color:#6B7280;font-size:12px;">مصاريف بنك تحمّلها النادي</div>
<div style="font-size:20px;font-weight:700;"><?= money((string) ($summary['total_bank_charges'] ?? '0')) ?></div>
<div style="color:#6B7280;font-size:12px;">محمّل للساحب: <?= money((string) ($summary['total_fees_billed'] ?? '0')) ?></div>
</div>
</div>
<!-- ── الدورة ─────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:18px;border-right:3px solid #0D7377;">
<div style="padding:14px 18px;color:#374151;font-size:12.5px;line-height:2;">
<strong>القيود اللي بتتعمل لما الشيك يرتد — تلات قيود منفصلة عن قصد:</strong>
<table style="margin-top:8px;font-size:12.5px;width:100%;">
<tr style="background:#F9FAFB;">
<td style="padding:6px 8px;"><strong>١ — الدين يرجع</strong></td>
<td style="padding:6px 8px;direction:rtl;">من ح/ <strong>شيكات مرتدة على الأعضاء</strong> — إلى ح/ <strong>شيكات تحت التحصيل</strong></td>
</tr>
<tr>
<td style="padding:6px 8px;"><strong>٢ — البنك خصم من النادي</strong></td>
<td style="padding:6px 8px;direction:rtl;">من ح/ <strong>مصروفات بنكية</strong> — إلى ح/ <strong>البنك</strong></td>
</tr>
<tr style="background:#F9FAFB;">
<td style="padding:6px 8px;"><strong>٣ — تحميل الساحب</strong> <span style="color:#6B7280;">(لو هو اللي يتحمّل)</span></td>
<td style="padding:6px 8px;direction:rtl;">من ح/ <strong>شيكات مرتدة على الأعضاء</strong> — إلى ح/ <strong>إيراد مصاريف ارتداد</strong></td>
</tr>
</table>
<div style="margin-top:8px;color:#6B7280;">
ليه منفصلين؟ عشان لو قررت تعفي العضو من المصاريف، ده <strong>ما يمسّش</strong>
أصل الدين. ومصاريف البنك بتتقيّد مهما كان القرار — الفلوس خرجت من حساب
النادي فعلًا، ولو ما اتقيّدتش المطابقة البنكية مش هتظبط أبدًا.
</div>
</div>
</div>
<!-- ── المرتدة ────────────────────────────────────────────── -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#DC2626;font-size:15px;">
شيكات مرتدة مفتوحة — <?= number_format(count($bounced)) ?>
</h3>
</div>
<?php if (empty($bounced)): ?>
<div style="padding:18px;color:#065F46;font-size:13px;">مفيش شيكات مرتدة مفتوحة.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table" style="font-size:12.5px;">
<thead>
<tr>
<th>الشيك</th>
<th>الساحب</th>
<th>المبلغ</th>
<th>سبب الارتداد</th>
<th>مرات الارتداد</th>
<th>من كام يوم</th>
<th>إجراء</th>
</tr>
</thead>
<tbody>
<?php foreach ($bounced as $c): ?>
<?php
$code = (string) ($c['bounce_code'] ?? '');
$criminal = !empty($reasons[$code]['criminal']);
$repeat = (int) $c['bounce_count'] >= 2;
?>
<tr>
<td>
<a href="/accounting/instruments/<?= (int) $c['id'] ?>" style="direction:ltr;display:inline-block;">
<?= e((string) $c['instrument_number']) ?>
</a>
<div style="color:#9CA3AF;font-size:11px;"><?= e((string) $c['drawer_bank']) ?></div>
</td>
<td>
<?= e((string) ($c['member_name'] ?: $c['drawer_name'] ?: '—')) ?>
<?php if (!empty($c['membership_number'])): ?>
<div style="color:#9CA3AF;font-size:11px;"><?= e((string) $c['membership_number']) ?></div>
<?php endif; ?>
</td>
<td style="font-weight:700;"><?= money((string) $c['amount']) ?></td>
<td>
<?= e($reasons[$code]['ar'] ?? (string) ($c['bounce_reason'] ?? '—')) ?>
<?php if ($criminal): ?>
<div><span class="badge badge-danger">مسؤولية جنائية محتملة</span></div>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $repeat ? 'badge-danger' : 'badge-warning' ?>">
<?= (int) $c['bounce_count'] ?>
</span>
<div style="color:#9CA3AF;font-size:11px;">قُدّم <?= (int) $c['presentation_count'] ?> مرة</div>
</td>
<td><?= number_format((int) ($c['days_since_bounce'] ?? 0)) ?> يوم</td>
<td style="white-space:nowrap;">
<?php if (can('accounting.instruments.manage')): ?>
<button type="button" class="btn btn-sm btn-outline"
onclick="openRepresent(<?= (int) $c['id'] ?>, '<?= e(addslashes((string) $c['instrument_number'])) ?>')">
قدّمه تاني
</button>
<button type="button" class="btn btn-sm btn-secondary"
onclick="openResolve(<?= (int) $c['id'] ?>, '<?= e(addslashes((string) $c['instrument_number'])) ?>')">
اقفل الموضوع
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- ── المتأخرة ───────────────────────────────────────────── -->
<div class="card" style="margin-bottom:18px;">
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h3 style="margin:0;color:#D97706;font-size:15px;">
متأخرة التحصيل — <?= number_format(count($overdue)) ?>
</h3>
<form method="GET" style="display:flex;gap:6px;align-items:center;">
<label style="font-size:12px;color:#6B7280;">مهلة سماح (أيام)</label>
<input type="number" min="0" name="grace" value="<?= (int) $grace ?>" class="form-input" style="width:90px;padding:5px 8px;font-size:12px;">
<button type="submit" class="btn btn-sm btn-outline">عرض</button>
</form>
</div>
<div style="padding:12px 18px;color:#92400E;font-size:12.5px;line-height:1.9;background:#FFFBEB;">
دول <strong>ما ارتدّوش</strong> — البنك ما قالش حاجة. يا إما الشيك لسه في
الخزنة وتاريخه عدّى، يا إما راح البنك وما رجعش رد. <strong>مفيش قيد بيتعمل
هنا</strong> — ده تنبيه متابعة، مش حدث محاسبي. القيد بيتعمل لما البنك يرد:
تحصيل أو ارتداد.
</div>
<?php if (empty($overdue)): ?>
<div style="padding:18px;color:#065F46;font-size:13px;">مفيش شيكات متأخرة.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table" style="font-size:12.5px;">
<thead>
<tr>
<th>الشيك</th>
<th>الساحب</th>
<th>المبلغ</th>
<th>تاريخ الاستحقاق</th>
<th>متأخر</th>
<th>نوع التأخير</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($overdue as $c): ?>
<?php $days = (int) ($c['days_overdue'] ?? 0); ?>
<tr>
<td><a href="/accounting/instruments/<?= (int) $c['id'] ?>" style="direction:ltr;display:inline-block;"><?= e((string) $c['instrument_number']) ?></a></td>
<td><?= e((string) ($c['member_name'] ?: $c['drawer_name'] ?: '—')) ?></td>
<td style="font-weight:700;"><?= money((string) $c['amount']) ?></td>
<td style="direction:ltr;text-align:right;"><?= e((string) $c['due_date']) ?></td>
<td>
<span class="badge <?= $days > 30 ? 'badge-danger' : 'badge-warning' ?>">
<?= number_format($days) ?> يوم
</span>
</td>
<td><?= e((string) $c['delay_kind']) ?></td>
<td>
<a class="btn btn-sm btn-outline" href="/accounting/instruments/<?= (int) $c['id'] ?>">افتح الشيك</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if (can('accounting.instruments.manage')): ?>
<!-- ── إعادة التقديم ──────────────────────────────────────── -->
<div id="representBox" class="card" style="display:none;margin-bottom:18px;border-right:3px solid #0D7377;">
<form method="POST" id="representForm">
<?= csrf_field() ?>
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#0D7377;font-size:15px;">إعادة تقديم الشيك — <span id="repNum"></span></h3>
</div>
<div style="padding:18px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:18px;">
<div class="form-group">
<label class="form-label">تاريخ التقديم</label>
<input type="date" name="date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
<div class="form-group">
<label class="form-label">البنك</label>
<select name="bank_account_id" class="form-input">
<option value="">— نفس البنك السابق —</option>
<?php foreach ($banks as $b): ?>
<option value="<?= (int) $b['id'] ?>"><?= e((string) $b['account_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label">ملاحظات</label>
<input type="text" name="notes" class="form-input" placeholder="مثال: الساحب أكّد إن الرصيد اتظبط">
</div>
<div style="margin-top:10px;padding:10px 12px;background:#F0F9FF;border-radius:6px;color:#075985;font-size:12.5px;line-height:1.9;">
الشيك هيرجع <strong>تحت التحصيل</strong> وهيتعمل قيد الإيداع تاني.
عدّاد مرات التقديم هيزيد — وده اللي بيفرّق بين غلطة مرة وبين نمط متكرر.
</div>
<div style="display:flex;gap:10px;margin-top:14px;">
<button type="submit" class="btn btn-primary">قدّمه تاني</button>
<button type="button" class="btn btn-outline" onclick="document.getElementById('representBox').style.display='none';">إلغاء</button>
</div>
</div>
</form>
</div>
<!-- ── الإقفال ────────────────────────────────────────────── -->
<div id="resolveBox" class="card" style="display:none;border-right:3px solid #7C3AED;">
<form method="POST" id="resolveForm">
<?= csrf_field() ?>
<div style="padding:14px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#7C3AED;font-size:15px;">إقفال موضوع الشيك — <span id="resNum"></span></h3>
</div>
<div style="padding:18px;">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:18px;">
<div class="form-group">
<label class="form-label">انتهى إزاي؟ <span style="color:#DC2626;">*</span></label>
<select name="resolution" id="resolutionSel" class="form-input" required
onchange="document.getElementById('writeOffNote').style.display = this.value === 'written_off' ? '' : 'none';">
<option value="">— اختار —</option>
<?php foreach ($resolutions as $k => $label): ?>
<option value="<?= e($k) ?>"><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">التاريخ</label>
<input type="date" name="date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
</div>
<div class="form-group" style="margin-top:12px;">
<label class="form-label">التفاصيل <span style="color:#DC2626;">*</span></label>
<textarea name="notes" class="form-input" rows="2" required
placeholder="مثال: العضو سدّد نقدًا بإيصال رقم ٤٥٦ / اتحوّل للمحامي بتاريخ كذا"></textarea>
</div>
<div id="writeOffNote" style="display:none;margin-top:10px;padding:12px 14px;background:#FEE2E2;border-radius:6px;color:#991B1B;font-size:12.5px;line-height:1.9;">
<strong>الإعدام بيتعمل له قيد:</strong>
<span style="direction:rtl;display:block;margin-top:4px;">
من ح/ <strong>ديون معدومة</strong> — إلى ح/ <strong>شيكات مرتدة على الأعضاء</strong>
</span>
بمبلغ الشيك <strong>+ أي مصاريف اتحمّلت على الساحب</strong> — لأنك بتتنازل
عن الاتنين. ده قرار إداري لازم يكون معتمد.
</div>
<div style="margin-top:10px;padding:10px 12px;background:#F9FAFB;border-radius:6px;color:#6B7280;font-size:12px;line-height:1.9;">
باقي الحالات <strong>مش بيتعمل لها قيد من هنا</strong>: السداد النقدي
بيتقيّد كتحصيل عادي، والشيك البديل ورقة جديدة بقيودها، والتقديم اللي نجح
اتقيّد وقت ما البنك حصّله. القيد هنا كان هيبقى تكرار.
</div>
<div style="display:flex;gap:10px;margin-top:14px;">
<button type="submit" class="btn btn-primary">اقفل الموضوع</button>
<button type="button" class="btn btn-outline" onclick="document.getElementById('resolveBox').style.display='none';">إلغاء</button>
</div>
</div>
</form>
</div>
<script>
function openRepresent(id, num) {
document.getElementById('representForm').action = '/accounting/instruments/' + id + '/represent';
document.getElementById('repNum').textContent = num;
document.getElementById('resolveBox').style.display = 'none';
var box = document.getElementById('representBox');
box.style.display = '';
box.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function openResolve(id, num) {
document.getElementById('resolveForm').action = '/accounting/instruments/' + id + '/resolve';
document.getElementById('resNum').textContent = num;
document.getElementById('representBox').style.display = 'none';
var box = document.getElementById('resolveBox');
box.style.display = '';
box.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
</script>
<?php endif; ?>
<?php $__template->endSection(); ?>
......@@ -82,6 +82,98 @@ $inst->exists = true;
$allowed = $inst->getAllowedTransitions();
?>
<?php if (!empty($allowed) && can('accounting.instruments.manage')): ?>
<?php if ($instrument['status'] === 'under_collection'): ?>
<!-- ── ارتداد الشيك ───────────────────────────────────────────
A bounce is not just a status. It carries the bank's reason, what the bank
charged the club, and who bears that charge — and each of those posts
differently. Recording it from the generic dropdown would set the status
and lose all three, so it has its own form. -->
<div class="card" style="margin-bottom:18px;border-right:3px solid #DC2626;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;color:#DC2626;">البنك ردّ الشيك؟ سجّل الارتداد</h3>
</div>
<form method="POST" action="/accounting/instruments/<?= (int)$instrument['id'] ?>/bounce">
<?= csrf_field() ?>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:2fr 1fr;gap:18px;">
<div class="form-group">
<label class="form-label">سبب الارتداد من البنك <span style="color:#DC2626;">*</span></label>
<select name="reason_code" id="bounceReason" class="form-input" required
onchange="document.getElementById('criminalNote').style.display = this.selectedOptions[0].dataset.criminal === '1' ? '' : 'none';">
<option value="">— اختار السبب —</option>
<?php foreach (\App\Modules\Accounting\Services\BouncedChequeService::REASONS as $k => $r): ?>
<option value="<?= e($k) ?>" data-criminal="<?= $r['criminal'] ? '1' : '0' ?>"><?= e($r['ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">تاريخ الارتداد</label>
<input type="date" name="date" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
</div>
<div id="criminalNote" style="display:none;margin:6px 0 14px;padding:10px 12px;background:#FEE2E2;border-radius:6px;color:#991B1B;font-size:12.5px;line-height:1.9;">
السبب ده بيرتّب <strong>مسؤولية جنائية</strong> على الساحب في القانون
المصري. سجّل <strong>رقم محضر/بروتستو البنك</strong> تحت — من غيره
الإثبات بيبقى أصعب لو الموضوع راح للقانون.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:18px;">
<div class="form-group">
<label class="form-label">البنك خصم من النادي كام؟</label>
<input type="number" step="0.01" min="0" name="bank_charge" class="form-input" value="0.00" style="direction:ltr;text-align:left;">
<small style="color:#6B7280;">من ح/ مصروفات بنكية — إلى ح/ البنك</small>
</div>
<div class="form-group">
<label class="form-label">مين يتحمّل المصاريف؟</label>
<select name="fee_bearer" id="feeBearer" class="form-input"
onchange="var f=document.getElementById('feeBox'); f.style.display = this.value === 'drawer' ? '' : 'none'; if(this.value!=='drawer'){document.querySelector('[name=fee]').value='0.00';}">
<option value="drawer">الساحب (نحمّله)</option>
<option value="club">النادي (نتحمّلها)</option>
</select>
</div>
<div class="form-group" id="feeBox">
<label class="form-label">اللي هنحمّله للساحب</label>
<input type="number" step="0.01" min="0" name="fee" class="form-input" value="0.00" style="direction:ltr;text-align:left;">
<small style="color:#6B7280;">قيد منفصل عن الدين</small>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 2fr;gap:18px;margin-top:8px;">
<div class="form-group">
<label class="form-label">رقم المحضر / البروتستو</label>
<input type="text" name="protest_number" class="form-input" style="direction:ltr;text-align:left;">
</div>
<div class="form-group">
<label class="form-label">تاريخ المحضر</label>
<input type="date" name="protest_date" class="form-input">
</div>
<div class="form-group">
<label class="form-label">ملاحظات</label>
<input type="text" name="notes" class="form-input" placeholder="أي تفاصيل من إشعار البنك">
</div>
</div>
<div style="margin-top:8px;padding:12px 14px;background:#F9FAFB;border-radius:6px;color:#374151;font-size:12.5px;line-height:1.9;">
<strong>هيتعمل:</strong>
<span style="direction:rtl;display:block;margin-top:4px;">
١ — من ح/ <strong>شيكات مرتدة على الأعضاء</strong> إلى ح/ <strong>شيكات تحت التحصيل</strong> (الدين رجع)<br>
٢ — من ح/ <strong>مصروفات بنكية</strong> إلى ح/ <strong>البنك</strong> (لو فيه خصم)<br>
٣ — من ح/ <strong>شيكات مرتدة</strong> إلى ح/ <strong>إيراد مصاريف ارتداد</strong> (لو الساحب هو اللي يتحمّل)
</span>
</div>
<div style="margin-top:14px;">
<button type="submit" class="btn" style="background:#DC2626;color:#fff;border:none;"
onclick="return confirm('هيتسجّل ارتداد الشيك وهتتعمل القيود. متأكد؟');">
سجّل الارتداد
</button>
</div>
</div>
</form>
</div>
<?php endif; ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">تغيير الحالة</h3>
......@@ -94,6 +186,7 @@ $allowed = $inst->getAllowedTransitions();
<label class="form-label">الحالة الجديدة</label>
<select name="new_status" class="form-select" required>
<?php foreach ($allowed as $s): ?>
<?php if ($s === 'bounced') { continue; } // has its own form above — see comment there ?>
<option value="<?= $s ?>"><?= NegotiableInstrument::$statusLabels[$s] ?? $s ?></option>
<?php endforeach; ?>
</select>
......
......@@ -172,6 +172,7 @@ MenuRegistry::register('accounting', [
['label_ar' => 'مراكز التكلفة', 'label_en' => 'Cost Centers', 'route' => '/accounting/cost-centers', 'permission' => 'accounting.cost_center.view', 'order' => 5],
['label_ar' => 'الموازنات التقديرية', 'label_en' => 'Budgets', 'route' => '/accounting/budgets', 'permission' => 'accounting.budget.view', 'order' => 6],
['label_ar' => 'الحسابات البنكية', 'label_en' => 'Bank Accounts', 'route' => '/accounting/bank-accounts', 'permission' => 'accounting.bank_account.view', 'order' => 7],
['label_ar' => 'الشيكات المرتدة', 'label_en' => 'Bounced Cheques', 'route' => '/accounting/instruments/bounced', 'permission' => 'accounting.instruments.view', 'order' => 33],
['label_ar' => 'الأوراق التجارية', 'label_en' => 'Instruments', 'route' => '/accounting/instruments', 'permission' => 'accounting.instruments.view', 'order' => 8],
['label_ar' => 'الأبعاد المحاسبية', 'label_en' => 'Dimensions', 'route' => '/accounting/dimensions', 'permission' => 'accounting.dimensions.view', 'order' => 9],
['label_ar' => 'المطابقة البنكية', 'label_en' => 'Bank Reconciliation', 'route' => '/accounting/bank-reconciliation', 'permission' => 'accounting.bank_recon.view', 'order' => 10],
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* What a bounced cheque needs the register to remember.
*
* The ledger side of a bounce was already right — the debt comes back onto the
* drawer and the collection account clears. What the register could not answer
* was everything the club actually needs when a cheque comes back:
*
* - **how many times it has been presented.** A cheque returned twice is a
* different conversation from one returned once, and in Egypt the number of
* presentations and the bank's return slip are what a case rests on.
* - **what the BANK took from us.** A bounce costs the club a charge on its
* own account. That is a real expense and a real credit to the bank, and
* without it the bank reconciliation will never tie out.
* - **who bears that cost** — the drawer or the club. Two different entries.
* - **how it ended.** Collected in cash, replaced with another cheque, sent to
* legal, or written off. Without this a bounced cheque stays "bounced" for
* ever and nobody can tell the open ones from the closed ones.
*
* `status` deliberately stays `bounced` after resolution — it DID bounce, and
* that is history. `resolution` is what says the matter is closed.
*/
return static function (Database $db): void {
$has = static function (string $column) use ($db): bool {
return $db->selectOne(
"SELECT 1 AS x FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'negotiable_instruments'
AND column_name = ?",
[$column]
) !== null;
};
$add = static function (string $sql) use ($db): void {
$db->raw("ALTER TABLE `negotiable_instruments` " . $sql);
};
if (!$has('presentation_count')) {
$add("ADD COLUMN `presentation_count` SMALLINT UNSIGNED NOT NULL DEFAULT 0
COMMENT 'كام مرة اتقدّم الشيك للبنك' AFTER `bounce_reason`");
}
if (!$has('bounce_count')) {
$add("ADD COLUMN `bounce_count` SMALLINT UNSIGNED NOT NULL DEFAULT 0
COMMENT 'كام مرة ارتد' AFTER `presentation_count`");
}
if (!$has('bounce_code')) {
$add("ADD COLUMN `bounce_code` VARCHAR(40) NULL
COMMENT 'سبب الارتداد المعياري من البنك' AFTER `bounce_count`");
}
if (!$has('bank_charge')) {
$add("ADD COLUMN `bank_charge` DECIMAL(18,2) NOT NULL DEFAULT 0.00
COMMENT 'اللي البنك خصمه من النادي' AFTER `bounce_code`");
}
if (!$has('fee_charged')) {
$add("ADD COLUMN `fee_charged` DECIMAL(18,2) NOT NULL DEFAULT 0.00
COMMENT 'اللي النادي حمّله للساحب' AFTER `bank_charge`");
}
if (!$has('fee_bearer')) {
$add("ADD COLUMN `fee_bearer` VARCHAR(10) NOT NULL DEFAULT 'drawer'
COMMENT 'drawer = الساحب يتحمّل | club = النادي يتحمّل' AFTER `fee_charged`");
}
if (!$has('resolution')) {
$add("ADD COLUMN `resolution` VARCHAR(30) NULL
COMMENT 'cash_settled | replaced | represented_collected | legal | written_off' AFTER `fee_bearer`");
}
if (!$has('resolved_date')) {
$add("ADD COLUMN `resolved_date` DATE NULL AFTER `resolution`");
}
if (!$has('resolution_notes')) {
$add("ADD COLUMN `resolution_notes` VARCHAR(500) NULL AFTER `resolved_date`");
}
if (!$has('replacement_instrument_id')) {
$add("ADD COLUMN `replacement_instrument_id` BIGINT UNSIGNED NULL
COMMENT 'الشيك البديل لو اتبدل' AFTER `resolution_notes`");
}
if (!$has('protest_number')) {
$add("ADD COLUMN `protest_number` VARCHAR(60) NULL
COMMENT 'رقم البروتستو / محضر البنك' AFTER `replacement_instrument_id`");
}
if (!$has('protest_date')) {
$add("ADD COLUMN `protest_date` DATE NULL AFTER `protest_number`");
}
// The two screens this feature adds both scan by direction + status + due
// date; neither combination was indexed.
$hasIdx = $db->selectOne(
"SELECT 1 AS x FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'negotiable_instruments'
AND index_name = 'idx_instr_dir_status_due'"
);
if (!$hasIdx) {
$db->raw("ALTER TABLE `negotiable_instruments`
ADD INDEX `idx_instr_dir_status_due` (`direction`, `status`, `due_date`)");
}
// Backfill: anything already collected or bounced was presented at least once.
$db->query(
"UPDATE negotiable_instruments
SET presentation_count = 1
WHERE presentation_count = 0
AND (deposited_date IS NOT NULL OR status IN ('collected', 'bounced'))"
);
$db->query(
"UPDATE negotiable_instruments
SET bounce_count = 1
WHERE bounce_count = 0 AND status = 'bounced'"
);
};
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