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 [ ...@@ -74,6 +74,11 @@ return [
['GET', '/accounting/instruments', 'Accounting\Controllers\NegotiableInstrumentController@index', ['auth'], 'accounting.instruments.view'], ['GET', '/accounting/instruments', 'Accounting\Controllers\NegotiableInstrumentController@index', ['auth'], 'accounting.instruments.view'],
['POST', '/accounting/instruments', 'Accounting\Controllers\NegotiableInstrumentController@store', ['auth', 'csrf'], 'accounting.instruments.manage'], ['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'], ['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'], ['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'], ['POST', '/accounting/instruments/{id:\d+}/change-status', 'Accounting\Controllers\NegotiableInstrumentController@changeStatus', ['auth', 'csrf'], 'accounting.instruments.manage'],
......
This diff is collapsed.
...@@ -56,6 +56,11 @@ final class CheckLifecycleService ...@@ -56,6 +56,11 @@ final class CheckLifecycleService
if (!empty($options['bank_account_id'])) { if (!empty($options['bank_account_id'])) {
$updateData['bank_account_id'] = (int) $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; break;
case 'collected': case 'collected':
......
This diff is collapsed.
...@@ -82,6 +82,98 @@ $inst->exists = true; ...@@ -82,6 +82,98 @@ $inst->exists = true;
$allowed = $inst->getAllowedTransitions(); $allowed = $inst->getAllowedTransitions();
?> ?>
<?php if (!empty($allowed) && can('accounting.instruments.manage')): ?> <?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 class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"> <div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">تغيير الحالة</h3> <h3 style="margin:0;">تغيير الحالة</h3>
...@@ -94,6 +186,7 @@ $allowed = $inst->getAllowedTransitions(); ...@@ -94,6 +186,7 @@ $allowed = $inst->getAllowedTransitions();
<label class="form-label">الحالة الجديدة</label> <label class="form-label">الحالة الجديدة</label>
<select name="new_status" class="form-select" required> <select name="new_status" class="form-select" required>
<?php foreach ($allowed as $s): ?> <?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> <option value="<?= $s ?>"><?= NegotiableInstrument::$statusLabels[$s] ?? $s ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
......
...@@ -172,6 +172,7 @@ MenuRegistry::register('accounting', [ ...@@ -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' => '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' => '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' => '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' => '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' => '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], ['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