Commit f39de0e0 authored by DevPilot's avatar DevPilot

feat(accounting): فصل الدمغة وض.ق.م حسب المصدر + ربط شيكات التقسيط بالأوراق التجارية

تنفيذ التلات بنود المعلّقة من ملف المعالجة المحاسبية:

١) الدمغة (23080405) بقت حساب رئيسي وتحتها:
   2308040501 دمغة عادية · 2308040502 دمغة إضافية
   الحساب مكانش عليه أي قيود فالفصل نضيف.

٢) ضريبة القيمة المضافة (23080404) بقت رئيسية وتحتها ٦ حسابات حسب مصدر
   الإيراد زي ما الملف بيفصلها:
   عام (أرصدة سابقة) · بيع استمارات · عضويات جديدة · كارنيهات ·
   إيجارات ١٪ · مصاريف
   الحساب كان عليه قيدين تاريخيين (افتتاحي + حركة 2024/2025 بإجمالي
   7,411,841.15) — اتنقلوا لحساب «عام» بالظبط زي ما هما، فالرصيد ما
   اتغيّرش والإقرار الضريبي بيتجمّع على الأب. الميزان قبل وبعد:
   512,971,418.03 مدين = دائن.

٣) «إلغاء ٢٥ ج مصاريف مقاصة عند ارتداد الشيك» — دي كانت مستحيلة قبل كده
   لأن شيكات التقسيط (installment_cheques) والأوراق التجارية
   (negotiable_instruments) مكانش بينهم أي رابط، فالنظام ما كانش يعرف
   الشيك اللي ارتد يخص أنهي قسط. دلوقتي:
   - عمود instrument_id + clearing_fee_reversed على installment_cheques.
   - ChequeService::registerAsInstruments — كل شيك تقسيط بيتسجّل كورقة
     قبض في السجل وقت الاستلام (زي ما الملف بيعتبرها أوراق قبض).
   - ChequeClearingFeeService::reverseForCheque — بيعكس رسم المقاصة بقيد
     مقابل بنفس حسابي القيد الأصلي وبقيمة الشيك ده لوحده (القيد الأصلي
     بيغطي كل شيكات الخطة فما ينفعش يتعكس كله).
   - BouncedChequeService::bounce بينادي عليه لما الورقة المرتدة تبقى
     مربوطة بشيك تقسيط. محمي من التكرار بـ clearing_fee_reversed.
parent 2c6ab082
......@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\ChequeClearingFeeService;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
/**
......@@ -143,6 +144,14 @@ final class BouncedChequeService
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$instrumentId]);
// ملف المعالجة المحاسبية: الشيك المرتد بتتحمّل عليه غرامة «مع الغاء
// مصاريف المقاصة» — لأن المقاصة نفسها ما تمّتش. لو الورقة دي مربوطة
// بشيك تقسيط اتحصّل عليه رسم مقاصة، بيتعكس هنا.
$linkedCheque = ChequeClearingFeeService::chequeForInstrument($instrumentId);
if ($linkedCheque) {
ChequeClearingFeeService::reverseForCheque((int) $linkedCheque['id']);
}
// 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) {
......
......@@ -5,6 +5,7 @@ namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Services\JournalService;
use App\Modules\Accounting\Services\Revenue\AccrualService;
/**
......@@ -55,6 +56,109 @@ final class ChequeClearingFeeService
}
}
/**
* عكس رسم المقاصة عن شيك ارتد.
*
* ملف المعالجة المحاسبية: الشيك المرتد بتتحمّل عليه غرامة، و«مع الغاء
* 25 ج مصاريف مقاصة» — لأن المقاصة نفسها ما تمّتش. الرسم اتحصّل وقت
* استلام الشيك، فبيتعكس بقيد مقابل بنفس الحسابين وبنفس المبلغ.
*
* @return array{reversed:bool, amount:string, error:?string}
*/
public static function reverseForCheque(int $chequeId): array
{
try {
$db = App::getInstance()->db();
$chq = $db->selectOne(
"SELECT * FROM installment_cheques WHERE id = ?",
[$chequeId]
);
if (!$chq) {
return ['reversed' => false, 'amount' => '0.00', 'error' => 'الشيك غير موجود'];
}
if ((int) $chq['clearing_fee_charged'] !== 1) {
return ['reversed' => false, 'amount' => '0.00', 'error' => null]; // مفيش رسم اتحصّل
}
if ((int) ($chq['clearing_fee_reversed'] ?? 0) === 1) {
return ['reversed' => false, 'amount' => '0.00', 'error' => null]; // اتعكس قبل كده
}
$fee = self::money((string) ($chq['clearing_fee_amount'] ?? '0'));
if (bccomp($fee, '0.01', self::SCALE) < 0) {
return ['reversed' => false, 'amount' => '0.00', 'error' => null];
}
// القيد الأصلي بيغطي كل شيكات الخطة، فما ينفعش نعكسه كله —
// بناخد منه الحسابين بس ونعمل قيد مقابل بقيمة الشيك ده لوحده.
$origin = $db->selectOne(
"SELECT id FROM journal_entries
WHERE reference_type = 'installment_cheque_clearing_fee'
AND reference_id = ? AND status = 'posted' AND is_archived = 0
ORDER BY id DESC LIMIT 1",
[(int) $chq['installment_plan_id']]
);
if (!$origin) {
return ['reversed' => false, 'amount' => '0.00', 'error' => 'قيد رسم المقاصة الأصلي غير موجود'];
}
$lines = $db->select(
"SELECT account_id, debit, credit FROM journal_entry_lines WHERE journal_entry_id = ?",
[(int) $origin['id']]
);
$debitAcc = $creditAcc = null;
foreach ($lines as $l) {
if (bccomp((string) $l['debit'], '0', self::SCALE) > 0) { $debitAcc = (int) $l['account_id']; }
if (bccomp((string) $l['credit'], '0', self::SCALE) > 0) { $creditAcc = (int) $l['account_id']; }
}
if ($debitAcc === null || $creditAcc === null || $debitAcc === $creditAcc) {
return ['reversed' => false, 'amount' => '0.00', 'error' => 'تعذّر تحديد حسابي القيد الأصلي'];
}
$num = $chq['cheque_number'] ?? ('#' . $chequeId);
$desc = 'إلغاء مصاريف مقاصة — شيك مرتد رقم ' . $num;
$result = JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $desc,
'reference_type' => 'installment_cheque_clearing_fee_reversal',
'reference_id' => $chequeId,
'reference_number' => (string) $num,
'source_module' => 'installments',
'is_auto_generated' => 1,
], [
// معكوس القيد الأصلي بالظبط
['account_id' => $creditAcc, 'debit' => $fee, 'credit' => '0.00', 'description_ar' => $desc],
['account_id' => $debitAcc, 'debit' => '0.00', 'credit' => $fee, 'description_ar' => $desc],
], true);
if (!($result['success'] ?? false)) {
return ['reversed' => false, 'amount' => '0.00', 'error' => $result['error'] ?? 'فشل قيد الإلغاء'];
}
$db->query(
"UPDATE installment_cheques SET clearing_fee_reversed = 1 WHERE id = ?",
[$chequeId]
);
Logger::info("ChequeClearingFeeService: reversed clearing fee {$fee} for cheque #{$chequeId}");
return ['reversed' => true, 'amount' => $fee, 'error' => null];
} catch (\Throwable $e) {
Logger::error('Clearing fee reversal failed: ' . $e->getMessage(), ['cheque_id' => $chequeId]);
return ['reversed' => false, 'amount' => '0.00', 'error' => $e->getMessage()];
}
}
/** شيك التقسيط المرتبط بورقة تجارية — لو فيه. */
public static function chequeForInstrument(int $instrumentId): ?array
{
return App::getInstance()->db()->selectOne(
"SELECT * FROM installment_cheques WHERE instrument_id = ? LIMIT 1",
[$instrumentId]
);
}
private static function run(int $planId): array
{
$db = App::getInstance()->db();
......
......@@ -186,6 +186,10 @@ class ChequeController extends Controller
return $this->redirect("/installments/{$planId}/cheques")->withError('خطأ أثناء الحفظ: ' . $e->getMessage());
}
// الشيكات بتتسجّل كأوراق قبض في سجل الأوراق التجارية عشان نقدر نتابع
// ارتدادها ونلغي مصاريف مقاصتها — زي ما ملف المعالجة المحاسبية بيقول.
ChequeService::registerAsInstruments((int) $planId);
// مصاريف مقاصة — billed per cheque just recorded, never folded into the
// plan's own total. See ChequeClearingFeeService for why.
ChequeClearingFeeService::chargeForPlan((int) $planId);
......@@ -323,6 +327,9 @@ class ChequeController extends Controller
'updated_at' => date('Y-m-d H:i:s'),
]);
// تسجيل الشيك كورقة قبض قبل تحميل الرسم — عشان الارتداد يلاقي الرابط.
ChequeService::registerAsInstruments((int) $planId);
// مصاريف مقاصة — billed for the cheque just recorded.
ChequeClearingFeeService::chargeForPlan((int) $planId);
......
......@@ -119,4 +119,77 @@ final class ChequeService
return ['success' => true, 'membership_number' => $number];
}
/**
* تسجيل شيكات خطة كأوراق قبض في سجل الأوراق التجارية.
*
* ملف المعالجة المحاسبية بيتعامل مع شيكات التقسيط كأوراق قبض تتودّع
* للتحصيل وممكن ترتد. من غير تسجيلها في سجل الأوراق التجارية مكانش فيه
* طريقة نتابع ارتدادها ولا نلغي مصاريف مقاصتها.
*
* بيتنفّذ مرة واحدة لكل شيك — اللي مربوط قبل كده بيتعدّى.
*
* @return int عدد الشيكات اللي اتسجّلت
*/
public static function registerAsInstruments(int $planId): int
{
$db = App::getInstance()->db();
$plan = $db->selectOne(
"SELECT ip.*, m.full_name_ar AS member_name, m.branch_id
FROM installment_plans ip
LEFT JOIN members m ON m.id = ip.member_id
WHERE ip.id = ?",
[$planId]
);
if (!$plan) {
return 0;
}
$cheques = $db->select(
"SELECT * FROM installment_cheques WHERE installment_plan_id = ? AND instrument_id IS NULL",
[$planId]
);
if (!$cheques) {
return 0;
}
$now = date('Y-m-d H:i:s');
$n = 0;
foreach ($cheques as $chq) {
try {
$instrumentId = $db->insert('negotiable_instruments', [
'instrument_type' => 'cheque',
'direction' => 'receivable',
'instrument_number' => (string) ($chq['cheque_number'] ?? ('INST-' . $chq['id'])),
'amount' => (string) $chq['cheque_amount'],
'currency' => 'EGP',
'issue_date' => $chq['cheque_date'] ?? date('Y-m-d'),
'due_date' => $chq['cheque_date'] ?? date('Y-m-d'),
'status' => 'in_hand',
'drawer_name' => $plan['member_name'] ?? null,
'drawer_bank' => $chq['bank_name'] ?? null,
'member_id' => !empty($plan['member_id']) ? (int) $plan['member_id'] : null,
'branch_id' => !empty($plan['branch_id']) ? (int) $plan['branch_id'] : null,
'notes' => 'شيك تقسيط — خطة رقم ' . $planId,
'created_at' => $now,
'updated_at' => $now,
]);
$db->query(
"UPDATE installment_cheques SET instrument_id = ? WHERE id = ?",
[$instrumentId, (int) $chq['id']]
);
$n++;
} catch (\Throwable $e) {
// تسجيل الورقة مش شرط لاستلام الشيك — بنكمّل ونسجّل الخطأ
Logger::error('Cheque instrument registration failed: ' . $e->getMessage(), [
'cheque_id' => $chq['id'] ?? null,
]);
}
}
return $n;
}
}
<?php
declare(strict_types=1);
/**
* فصل الدمغة وضريبة القيمة المضافة حسب ملف المعالجة المحاسبية.
*
* الملف بيفصل:
* - الدمغة إلى «عادية» و«إضافية» (بندين مستقلين في كل قيد مصروف).
* - ض.ق.م حسب مصدر الإيراد: استمارات / عضويات جديدة / كارنيهات /
* إيجارات ١٪ / مصاريف.
* والدليل كان فيه حساب واحد لكل منهم.
*
* الدمغة (23080405) مفيهاش أي قيود، فبتتحوّل لحساب رئيسي وتحتها الاتنين.
*
* ض.ق.م (23080404) فيها قيدين تاريخيين (الأرصدة الافتتاحية وحركة الفترة
* ٢٠٢٤/٢٠٢٥ — إجمالي 7,411,841.15). ما ينفعش نخليها رئيسية وفيها قيود،
* فبنعمل تحتها حساب «عام» وننقّل له القيدين بالظبط زي ما هما، وبعدين
* تبقى رئيسية. الرصيد ما بيتغيّرش — بيتنقل مكانه في الشجرة بس،
* والإقرار الضريبي بيفضل يتجمّع على الأب.
*/
return [
'up' => "
INSERT INTO `chart_of_accounts`
(`account_code`,`name_ar`,`name_en`,`account_type`,`account_nature`,`parent_id`,`level`,`level_name`,`is_header`,`is_active`,`created_at`,`updated_at`)
SELECT '2308040501','دمغة عادية','Ordinary stamp duty','liability','credit',id,6,'تحليلي',0,1,NOW(),NOW()
FROM `chart_of_accounts` WHERE `account_code` = '23080405';
INSERT INTO `chart_of_accounts`
(`account_code`,`name_ar`,`name_en`,`account_type`,`account_nature`,`parent_id`,`level`,`level_name`,`is_header`,`is_active`,`created_at`,`updated_at`)
SELECT '2308040502','دمغة إضافية','Additional stamp duty','liability','credit',id,6,'تحليلي',0,1,NOW(),NOW()
FROM `chart_of_accounts` WHERE `account_code` = '23080405';
UPDATE `chart_of_accounts` SET `is_header` = 1 WHERE `account_code` = '23080405';
INSERT INTO `chart_of_accounts`
(`account_code`,`name_ar`,`name_en`,`account_type`,`account_nature`,`parent_id`,`level`,`level_name`,`is_header`,`is_active`,`created_at`,`updated_at`)
SELECT v.code, v.ar, v.en,'liability','credit', p.id, 6,'تحليلي',0,1,NOW(),NOW()
FROM (
SELECT '2308040401' AS code,'ض.ق.م — عام (أرصدة سابقة)' AS ar,'VAT — general' AS en UNION ALL
SELECT '2308040402','ض.ق.م — بيع استمارات العضوية','VAT — membership forms' UNION ALL
SELECT '2308040403','ض.ق.م — العضويات الجديدة','VAT — new memberships' UNION ALL
SELECT '2308040404','ض.ق.م — الكارنيهات','VAT — membership cards' UNION ALL
SELECT '2308040405','ض.ق.م — الإيجارات ١٪','VAT — rentals 1%' UNION ALL
SELECT '2308040406','ض.ق.م — المصاريف','VAT — expenses'
) v
JOIN `chart_of_accounts` p ON p.`account_code` = '23080404';
UPDATE `journal_entry_lines` SET `account_id` =
(SELECT id FROM `chart_of_accounts` WHERE `account_code` = '2308040401')
WHERE `account_id` = (SELECT id FROM (SELECT id FROM `chart_of_accounts` WHERE `account_code` = '23080404') x);
UPDATE `chart_of_accounts` SET `is_header` = 1 WHERE `account_code` = '23080404';
",
'down' => "
UPDATE `journal_entry_lines` SET `account_id` =
(SELECT id FROM `chart_of_accounts` WHERE `account_code` = '23080404')
WHERE `account_id` = (SELECT id FROM (SELECT id FROM `chart_of_accounts` WHERE `account_code` = '2308040401') x);
UPDATE `chart_of_accounts` SET `is_header` = 0 WHERE `account_code` IN ('23080404','23080405');
DELETE FROM `chart_of_accounts` WHERE `account_code` IN
('2308040501','2308040502','2308040401','2308040402','2308040403','2308040404','2308040405','2308040406');
",
];
<?php
declare(strict_types=1);
/**
* ربط شيكات التقسيط بالأوراق التجارية.
*
* ملف المعالجة المحاسبية بيقول: «يتم اضافة 50 ج على قيمة كل شيك مرتد عند
* تحصيله مع الغاء 25 ج مصاريف مقاصة». الشطر التاني ما كانش ممكن يتنفّذ
* أصلاً: شيكات التقسيط متخزّنة في installment_cheques، والارتداد بيحصل على
* negotiable_instruments، والجدولين مكانش بينهم أي رابط — فالنظام ما كانش
* يعرف الشيك اللي ارتد ده يخص أنهي قسط عشان يلغي مصاريف مقاصته.
*
* العمود ده هو الرابط. بيتملى لما الشيك يتسجّل كورقة تجارية، وعند الارتداد
* النظام بيرجع منه لشيك التقسيط ويعكس رسم المقاصة المحصّل عليه.
*/
return [
'up' => "
ALTER TABLE `installment_cheques`
ADD COLUMN `instrument_id` BIGINT UNSIGNED NULL AFTER `installment_plan_id`,
ADD COLUMN `clearing_fee_reversed` TINYINT(1) NOT NULL DEFAULT 0 AFTER `clearing_fee_amount`,
ADD KEY `idx_inst_cheque_instrument` (`instrument_id`);
ALTER TABLE `installment_cheques`
ADD CONSTRAINT `fk_inst_cheque_instrument` FOREIGN KEY (`instrument_id`)
REFERENCES `negotiable_instruments` (`id`) ON DELETE SET NULL;
",
'down' => "
ALTER TABLE `installment_cheques` DROP FOREIGN KEY `fk_inst_cheque_instrument`;
ALTER TABLE `installment_cheques`
DROP COLUMN `instrument_id`,
DROP COLUMN `clearing_fee_reversed`;
",
];
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