Commit 4729ee75 authored by DevPilot's avatar DevPilot

feat(accounting): wire the cheque-clearing fee into installment intake

Finished what last night shipped as configured-but-manual. Mapped the
Installments module properly this time: cheques for a membership
installment plan live in `installment_cheques`, a table with no status
lifecycle and no connection to the accounting cheque system at all
(negotiable_instruments sits at zero rows) — two insert sites
(ChequeController::storeBatch / store) plus a historical-backfill path
in RetroactiveMembershipService.

The fee cannot be folded into the plan's own total: `total_with_interest`
is recalculated from principal and interest alone
(InstallmentController::recalculate) and both intake paths hard-validate
cheques against it — anything added would be silently erased or reported
as a shortfall. So it bills as its own claim, the same shape as every
other accrual in this system: Dr member receivable / Cr the clearing-fee
revenue account already sitting in the chart from last night
(410540), collected alongside the member's next ordinary payment.

The billing unit is the CHEQUE, not the batch, via a
`clearing_fee_charged` flag added to installment_cheques — which is also
the idempotency key. A plan finished across three cashier visits bills
three times for exactly the cheques each visit added; a retried request
bills nothing twice. The 102 live cheques already on file are marked
charged in the same migration — they were handed over before this
feature existed, and backfilling a charge onto them would invent
something no member agreed to.

Verified against a scratch copy of production: a fresh 3-cheque batch
bills exactly 75.00 across 3 receivables, a replay bills nothing, a
second visit adding 2 more cheques to the same plan bills exactly
50.00, a branch with no fee configured bills nothing and leaves those
cheques correctly unbilled (not stuck), and the historical 102 stay
untouched. Trial balance nets to 0.00.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent d74b0ee5
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Services\Revenue\AccrualService;
/**
* مصاريف مقاصة — the fee added per cheque a member hands over for an
* installment plan (spec rows 28-29), billed the moment the cheques are
* recorded rather than folded into the plan's principal.
*
* It cannot be folded in. `installment_plans.total_with_interest` is
* recalculated from `total_amount - down_payment` by
* `InstallmentController::recalculate()` and hard-validated against the sum of
* cheques by both intake paths in ChequeController — any fee added to that
* figure would be silently erased by the first recalculation, or reported as a
* shortfall the member's cheques don't cover. A clearing fee is not principal
* or interest; it is billed as its own claim, the same way the accrual gap
* tools bill an academy deposit as its own claim rather than folding it into
* rent.
*
* The unit of billing is the CHEQUE, not the batch: `installment_cheques.
* clearing_fee_charged` is both what gets billed and the idempotency key,
* so a plan finished across three separate visits to the cashier bills three
* times for exactly the cheques each visit added, and a retried request bills
* nothing twice.
*/
final class ChequeClearingFeeService
{
private const SCALE = 2;
private const STREAM_CODE = 'installment:cheque_clearing_fee';
/**
* Bill for every not-yet-charged cheque on a plan. Called right after a
* cheque intake commits — batch or single, it is the same operation,
* scoped by whatever rows are actually unbilled at the time.
*
* Never throws: a cheque must still be recorded even if the fee cannot be
* posted (an unmapped account, a missing branch). The intake proceeds and
* the shortfall is logged, exactly like every other auto-posting path in
* this system.
*
* @return array{billed:int, total:string, error:?string}
*/
public static function chargeForPlan(int $planId): array
{
try {
return self::run($planId);
} catch (\Throwable $e) {
Logger::error('Cheque clearing fee posting failed: ' . $e->getMessage(), ['plan_id' => $planId]);
return ['billed' => 0, 'total' => '0.00', 'error' => $e->getMessage()];
}
}
private static function run(int $planId): array
{
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT ip.id, ip.member_id, m.branch_id
FROM installment_plans ip
JOIN members m ON m.id = ip.member_id
WHERE ip.id = ?",
[$planId]
);
if (!$plan) {
return ['billed' => 0, 'total' => '0.00', 'error' => null];
}
$branchId = $plan['branch_id'] !== null ? (int) $plan['branch_id'] : null;
$fee = BranchFeeService::amount('cheque_clearing_fee', $branchId);
if (bccomp($fee, '0.00', self::SCALE) <= 0) {
return ['billed' => 0, 'total' => '0.00', 'error' => null]; // not configured for this branch
}
$unbilled = $db->select(
"SELECT id, cheque_number FROM installment_cheques
WHERE installment_plan_id = ? AND clearing_fee_charged = 0
ORDER BY id",
[$planId]
);
if (!$unbilled) {
return ['billed' => 0, 'total' => '0.00', 'error' => null];
}
$memberId = (int) $plan['member_id'];
$items = [];
foreach ($unbilled as $chq) {
$items[] = [
'document_id' => (int) $chq['id'],
'member_id' => $memberId,
'amount' => $fee,
'document_number' => $chq['cheque_number'] ?? null,
'description_ar' => 'مصاريف مقاصة شيك رقم ' . ($chq['cheque_number'] ?? ('#' . $chq['id'])),
];
}
$result = AccrualService::batch(self::STREAM_CODE, $items, [
'document_type' => 'installment_cheque',
'source_module' => 'installments',
'branch_id' => $branchId,
'entry_date' => date('Y-m-d'),
'description_ar' => 'مصاريف مقاصة شيكات — خطة تقسيط رقم ' . $planId,
'reference_type' => 'installment_cheque_clearing_fee',
'reference_id' => $planId,
]);
if (!$result['posted']) {
if ($result['error'] !== null) {
Logger::error('Cheque clearing fee not posted', ['plan_id' => $planId, 'error' => $result['error']]);
}
return ['billed' => 0, 'total' => '0.00', 'error' => $result['error']];
}
// Mark exactly the cheques this call billed for — not the whole plan,
// in case another visit adds more before this one is even reviewed.
$ids = array_column($unbilled, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$db->query(
"UPDATE installment_cheques SET clearing_fee_charged = 1, clearing_fee_amount = ?
WHERE id IN ({$placeholders})",
array_merge([$fee], $ids)
);
return ['billed' => $result['count'], 'total' => $result['total'], 'error' => null];
}
}
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\Installments\Services\ChequeService; use App\Modules\Installments\Services\ChequeService;
use App\Modules\Accounting\Services\ChequeClearingFeeService;
class ChequeController extends Controller class ChequeController extends Controller
{ {
...@@ -185,6 +186,10 @@ class ChequeController extends Controller ...@@ -185,6 +186,10 @@ class ChequeController extends Controller
return $this->redirect("/installments/{$planId}/cheques")->withError('خطأ أثناء الحفظ: ' . $e->getMessage()); return $this->redirect("/installments/{$planId}/cheques")->withError('خطأ أثناء الحفظ: ' . $e->getMessage());
} }
// مصاريف مقاصة — billed per cheque just recorded, never folded into the
// plan's own total. See ChequeClearingFeeService for why.
ChequeClearingFeeService::chargeForPlan((int) $planId);
// Check if count threshold reached → activate member // Check if count threshold reached → activate member
if (ChequeService::allChequesSubmitted((int) $planId)) { if (ChequeService::allChequesSubmitted((int) $planId)) {
$result = ChequeService::activateMemberAfterCheques((int) $planId); $result = ChequeService::activateMemberAfterCheques((int) $planId);
...@@ -318,6 +323,9 @@ class ChequeController extends Controller ...@@ -318,6 +323,9 @@ class ChequeController extends Controller
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
]); ]);
// مصاريف مقاصة — billed for the cheque just recorded.
ChequeClearingFeeService::chargeForPlan((int) $planId);
// Check if all cheques now submitted → activate member // Check if all cheques now submitted → activate member
if (ChequeService::allChequesSubmitted((int) $planId)) { if (ChequeService::allChequesSubmitted((int) $planId)) {
$result = ChequeService::activateMemberAfterCheques((int) $planId); $result = ChequeService::activateMemberAfterCheques((int) $planId);
......
...@@ -679,6 +679,11 @@ final class RetroactiveMembershipService ...@@ -679,6 +679,11 @@ final class RetroactiveMembershipService
'bank_name' => $cheque['bank_name'] ?? 'غير محدد', 'bank_name' => $cheque['bank_name'] ?? 'غير محدد',
'cheque_date' => self::safeDate($cheque['cheque_date'] ?? null) ?? date('Y-m-d'), 'cheque_date' => self::safeDate($cheque['cheque_date'] ?? null) ?? date('Y-m-d'),
'cheque_amount' => $cheque['cheque_amount'] ?? '0.00', 'cheque_amount' => $cheque['cheque_amount'] ?? '0.00',
// A retroactive entry documents something that already
// happened under whatever practice existed at the time —
// it must never invent a clearing-fee charge nobody agreed
// to when the cheque was actually written.
'clearing_fee_charged' => 1,
'scan_path' => 'retroactive/no-scan.pdf', 'scan_path' => 'retroactive/no-scan.pdf',
'uploaded_by' => $empId, 'uploaded_by' => $empId,
'notes' => 'شيك بأثر رجعي — بدون صورة', 'notes' => 'شيك بأثر رجعي — بدون صورة',
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The cheque-clearing fee (spec rows 28-29) had nowhere to attach to. Every
* post-dated cheque a member hands over for an installment plan is recorded in
* `installment_cheques` — a table with no status lifecycle and no link to the
* accounting cheque system at all (`negotiable_instruments` sits at zero rows;
* the two have never talked to each other).
*
* `clearing_fee_charged` is the unit the fee attaches to: one flag per cheque
* row, set the moment ChequeClearingFeeService has billed for it. That makes a
* batch of N cheques bill for exactly N fees, a plan finished across several
* visits bill only for the cheques each visit actually added, and a retry of
* the same request bill nothing a second time — the flag IS the idempotency
* key, nothing else to get out of sync with it.
*
* The 102 cheques already on file are marked charged in this same migration.
* They were handed over under whatever practice existed before this feature —
* backfilling a fee onto them now would invent a charge no member agreed to
* when they wrote the cheque.
*/
return [
'up' => static function (Database $db): void {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'installment_cheques'
AND column_name = 'clearing_fee_charged'"
);
if ((int) ($exists['n'] ?? 0) > 0) {
return;
}
$db->raw("
ALTER TABLE installment_cheques
ADD COLUMN clearing_fee_charged TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = a مصاريف مقاصة charge has been billed for this cheque'
AFTER cheque_amount,
ADD COLUMN clearing_fee_amount DECIMAL(10,2) NULL
COMMENT 'what was actually billed for this cheque — the branch rate at the time, not a live lookup'
AFTER clearing_fee_charged
");
// Existing cheques predate the feature — never bill them retroactively.
$db->raw("UPDATE installment_cheques SET clearing_fee_charged = 1 WHERE clearing_fee_charged = 0");
},
'down' => static function (Database $db): void {
$exists = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'installment_cheques'
AND column_name = 'clearing_fee_charged'"
);
if ((int) ($exists['n'] ?? 0) > 0) {
$db->raw("ALTER TABLE installment_cheques DROP COLUMN clearing_fee_charged, DROP COLUMN clearing_fee_amount");
}
},
];
...@@ -14,14 +14,10 @@ use App\Core\Database; ...@@ -14,14 +14,10 @@ use App\Core\Database;
* or "cancel" each one. Generalizing from here is one click on the branch-fees * or "cancel" each one. Generalizing from here is one click on the branch-fees
* screen, not a redeploy. * screen, not a redeploy.
* *
* The cheque-clearing fee (25 EGP, row 28) is seeded active too — finance HAS * The cheque-clearing fee (25 EGP, row 28) is billed automatically by
* given a number for it — but nothing in this deployment automatically charges * ChequeClearingFeeService the moment a cheque is recorded on an installment
* it yet: it belongs on the installment/cheque-intake screen, a separate * plan — see Phase_112_001_seed_cheque_clearing_stream.php for the posting
* legacy subsystem (App\Modules\Installments) that does not yet talk to the * rule it routes through.
* accounting cheque lifecycle at all (zero rows in negotiable_instruments).
* Wiring a live charge into that flow blind, hours before a meeting, was a
* worse risk than shipping the number configured-and-visible with an honest
* note. It shows on the branch-fees screen so it is not silently missing.
* *
* Idempotent. * Idempotent.
*/ */
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The stream and posting rule ChequeClearingFeeService routes through.
*
* Registered as its own stream — `installment:cheque_clearing_fee` — rather
* than folded into an existing one, because it is billed at a different
* moment (cheque intake) and to a different account (410540, created in
* Phase_111_002) than anything else in the installment flow.
*
* The rule debits the member's receivable and credits the clearing-fee
* revenue account — the same shape every other accrual in this system uses,
* so it clears through the normal collection path (the member pays it along
* with an ordinary invoice or subscription) with no special handling needed
* at that end.
*/
return static function (Database $db): void {
$code = 'installment:cheque_clearing_fee';
$now = date('Y-m-d H:i:s');
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if (!$stream) {
$streamId = $db->insert('revenue_streams', [
'stream_code' => $code,
'name_ar' => 'مصاريف مقاصة شيكات الأقساط',
'name_en' => 'Installment Cheque Clearing Fee',
'source_module' => 'installments',
'source_event' => null,
'wiring_status' => 'dispatches',
'wiring_note' => 'بيتقيّد من ChequeClearingFeeService وقت استلام كل شيك — مش حدث، مكالمة مباشرة.',
'category' => 'membership',
'default_direction' => 'inflow',
'is_system' => 1,
'is_active' => 1,
'notes' => 'من ملف تعليمات المحاسب — ٢٥ جنيه على كل شيك في البيع بالتقسيط.',
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$streamId = (int) $stream['id'];
}
$existingRule = $db->selectOne(
"SELECT id FROM revenue_posting_rules WHERE stream_id = ? AND stage = 'accrual' AND status = 'active'",
[$streamId]
);
if ($existingRule) {
return; // finance may have already adjusted this — never overwrite
}
$memberArAccount = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '120301004' AND is_active = 1"
);
$feeRevenueAccount = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '410540' AND is_active = 1"
);
if (!$memberArAccount || !$feeRevenueAccount) {
return; // chart differs on this deployment — leave it to the mapping screen
}
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => 1,
'stage' => 'accrual',
'direction' => 'inflow',
'name_ar' => 'استحقاق مصاريف مقاصة شيك',
'debit_account_id' => (int) $memberArAccount['id'],
'debit_source' => 'accounts_receivable',
'status' => 'active',
'effective_from' => date('Y-m-d'),
'notes' => 'بتتقيّد على مدين العضو، وبتتحصّل معاه في أي دفعة عادية.',
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => 'revenue',
'allocation_method' => 'remainder',
'account_id' => (int) $feeRevenueAccount['id'],
'description_ar' => 'مصاريف مقاصة شيك',
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
};
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