Commit 9f48d4aa authored by DevPilot's avatar DevPilot

fix(accounting): make the advances reclassification actually runnable, and safe afterwards

Phase_107_002 has never run. Its first INSERT wrote to chart_of_accounts.notes,
a column that does not exist, so the migration threw and MigrationRunner swallowed
it — leaving 3,032,258 EGP of member advances sitting in revenue this whole time.
Writing to description_ar instead lets it complete.

That exposed a second problem the original migration would have caused. It
deactivates 410503/504/505, but payment:down_payment carries an active posting
rule crediting 410503. Deactivating the account without moving the rule means
every future down payment records a receipt and posts no journal entry, because
JournalService refuses an inactive account — quietly worse than the overstatement
being fixed. The rule now moves to the matching contract-liability account, which
is where a down payment belonged anyway: it is an advance from the moment it is
collected, and only becomes revenue as the service is delivered. Otherwise the
account being emptied would just refill.

Also seeds the four bank accounts the chart already names as the club's current
accounts. bank_accounts was empty, which is why the cash chain's deposit step
had nothing to resolve and reported red. Account numbers are deliberate
placeholders reading "رقم الحساب غير محدد" — a seed has no business inventing an
IBAN that could reach a printed deposit slip — and the seed never overwrites a
row someone has already filled in.

And drops POST /waivers/{id}/pay: WaiverController::pay() does not exist, so the
route was a guaranteed 500. Leftover from a direct-payment design that
send-to-cashier replaced; nothing posts to it.

Verified on a full 369-table production clone: balances move (410503/504/505 -> 0,
deactivated; 23081119/20/21 created), entry balances Dr=Cr=3,032,258.00, re-runs
post nothing, down payments still post clean, no active rule anywhere resolves to
a dead or header account, all four chains green, trial balance diff 0.00.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent f4ef51e9
......@@ -6,7 +6,10 @@ return [
['GET', '/waivers/create/{memberId}', 'Waiver\Controllers\WaiverController@create', ['auth'], 'waiver.initiate'],
['POST', '/waivers/store/{memberId}', 'Waiver\Controllers\WaiverController@store', ['auth', 'csrf'], 'waiver.initiate'],
['GET', '/waivers/{id}', 'Waiver\Controllers\WaiverController@show', ['auth'], 'waiver.view'],
['POST', '/waivers/{id}/pay', 'Waiver\Controllers\WaiverController@pay', ['auth', 'csrf'], 'payment.collect'],
// '/waivers/{id}/pay' removed — WaiverController::pay() does not exist, so
// the route was a guaranteed 500. It is a leftover from a direct-payment
// design that send-to-cashier replaced: nothing in any view posts to it,
// and the live flow raises a payment request the cashier then collects.
['POST', '/waivers/{id}/send-to-cashier','Waiver\Controllers\WaiverController@sendToCashier', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/approve', 'Waiver\Controllers\WaiverController@approve', ['auth', 'csrf'], 'waiver.approve'],
['POST', '/waivers/{id}/reject', 'Waiver\Controllers\WaiverController@reject', ['auth', 'csrf'], 'waiver.approve'],
......
......@@ -82,7 +82,11 @@ return static function (Database $db): void {
'is_active' => 1,
'is_archived' => 0,
'current_balance' => '0.00',
'notes' => 'مُنشأ بقيد تصحيح ' . $REF . ' — نقل مقدمات الأعضاء من الإيرادات للالتزامات',
// chart_of_accounts has description_ar/description_en — there is
// no `notes` column. Writing to one made this whole migration
// throw on its first INSERT, so it has never run: the 3,032,258
// is still sitting in revenue.
'description_ar' => 'مُنشأ بقيد تصحيح ' . $REF . ' — نقل مقدمات الأعضاء من الإيرادات للالتزامات',
'created_at' => $now,
'updated_at' => $now,
]);
......@@ -133,13 +137,54 @@ return static function (Database $db): void {
throw new \RuntimeException('فشل قيد إعادة التبويب: ' . ($result['error'] ?? 'سبب غير معروف'));
}
// Re-point whatever still POSTS to these accounts before deactivating them.
//
// `payment:down_payment` carries an active rule crediting ٤١٠٥٠٣. Deactivate
// that account without moving the rule and every future down payment stops
// posting entirely — JournalService rejects an inactive account, so the
// collection would record a receipt and no journal entry, which is a worse
// problem than the one this migration set out to fix.
//
// Moving the rule is also the correct answer on its own terms: a down
// payment IS an advance. It belongs in the contract liability from the
// moment it is collected, and only becomes revenue as the service is
// delivered. Leaving it crediting revenue would just refill the account
// this migration is emptying.
foreach ($map as $oldCode => $new) {
$oldAcc = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$oldCode]);
$newAcc = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$new['code']]);
if (!$oldAcc || !$newAcc) {
continue;
}
// The allocation lines that credit the old revenue account.
$db->query(
"UPDATE revenue_posting_rule_lines l
JOIN revenue_posting_rules r ON r.id = l.rule_id AND r.status = 'active'
SET l.account_id = ?,
l.line_type = 'passthrough',
l.description_ar = CONCAT(COALESCE(l.description_ar, ''), ' — مقبوض مقدمًا (التزام)'),
l.updated_at = ?
WHERE l.account_id = ? AND l.is_active = 1",
[(int) $newAcc['id'], $now, (int) $oldAcc['id']]
);
// And any rule using it as the fixed counter account.
$db->query(
"UPDATE revenue_posting_rules
SET debit_account_id = ?, updated_at = ?
WHERE debit_account_id = ? AND status = 'active'",
[(int) $newAcc['id'], $now, (int) $oldAcc['id']]
);
}
// Stop anything posting to the revenue-side accounts again. They stay
// visible with a zero balance so the history reads straight.
foreach (array_keys($map) as $oldCode) {
$db->query(
"UPDATE chart_of_accounts
SET is_active = 0,
notes = CONCAT(COALESCE(notes, ''), ' | موقوف بقيد ', ?, ' — الرصيد اتنقل لحساب الالتزامات المقابل'),
description_ar = CONCAT(COALESCE(description_ar, ''), ' | موقوف بقيد ', ?, ' — الرصيد اتنقل لحساب الالتزامات المقابل'),
updated_at = ?
WHERE account_code = ? AND account_type = 'revenue'",
[$REF, $now, $oldCode]
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* `bank_accounts` was completely empty, which is why the treasury cash chain
* showed red on its last step: the bank-deposit hop resolves its account from
* the deposit slip's bank, and there was no bank to pick.
*
* The four banks below are not invented — the chart of accounts already names
* them as the club's current accounts (١٢٠٦٠٢٠١–٠٤ under «نقدية بالبنوك حسابات
* جارية»), and the posting rules already resolve card and transfer collections
* to البنك الأهلي. This seed just creates the matching `bank_accounts` rows so
* the deposit screen has something to select and the chain can close.
*
* The ACCOUNT NUMBERS are deliberately placeholders. A real IBAN is not
* something a seed should invent — it would look like data, and it could end up
* on a printed deposit slip. Each one reads «رقم الحساب غير محدد» in Arabic so
* nobody mistakes it for the real thing, and the notes say what to do. Update
* them from /accounting/bank-accounts before the first real deposit.
*
* Idempotent — an existing row for a bank is left exactly as it is, so this
* never overwrites a real account number someone has already entered.
*/
return static function (Database $db): void {
$banks = [
['code' => '12060201', 'ar' => 'البنك الأهلي المصري — حساب جاري', 'en' => 'National Bank of Egypt — Current', 'bank' => 'البنك الأهلي المصري', 'default' => 1],
['code' => '12060202', 'ar' => 'بنك مصر — حساب جاري', 'en' => 'Banque Misr — Current', 'bank' => 'بنك مصر', 'default' => 0],
['code' => '12060203', 'ar' => 'بنك القاهرة — حساب جاري', 'en' => 'Banque du Caire — Current', 'bank' => 'بنك القاهرة', 'default' => 0],
['code' => '12060204', 'ar' => 'بنك التعمير والإسكان — حساب جاري','en' => 'HDB — Current', 'bank' => 'بنك التعمير والإسكان','default' => 0],
];
$now = date('Y-m-d H:i:s');
foreach ($banks as $b) {
$gl = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_header = 0 AND is_active = 1 AND is_archived = 0",
[$b['code']]
);
if (!$gl) {
continue; // chart differs on this deployment
}
// Already linked to this GL account? Leave it — it may carry a real number.
$exists = $db->selectOne("SELECT id FROM bank_accounts WHERE gl_account_id = ?", [(int) $gl['id']]);
if ($exists) {
continue;
}
$placeholder = 'رقم الحساب غير محدد — ' . $b['code'];
if ($db->selectOne("SELECT id FROM bank_accounts WHERE account_number = ?", [$placeholder])) {
continue;
}
$db->insert('bank_accounts', [
'account_name_ar' => $b['ar'],
'account_name_en' => $b['en'],
'bank_name_ar' => $b['bank'],
'account_number' => $placeholder,
'currency' => 'EGP',
'gl_account_id' => (int) $gl['id'],
'opening_balance' => '0.00',
'current_balance' => '0.00',
'is_default' => $b['default'],
'is_active' => 1,
'is_archived' => 0,
'notes' => 'اتعمل تلقائيًا عشان خطوة الإيداع البنكي في سلسلة النقدية تشتغل. '
. 'رقم الحساب ده مؤقت — عدّله برقم الحساب/الآيبان الحقيقي من شاشة '
. 'الحسابات البنكية قبل أول إيداع فعلي.',
'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