Commit 2780509a authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(accounting): billing sources installed none — information_schema case, and wire cheques

TWO THINGS.

1. Billing sources installed nothing.
   validate() read information_schema with lower-case keys (column_name,
   data_type) while this server returns them upper case, so every column looked
   missing, every source failed validation, and the seed skipped all seven while
   reporting success. Columns are now aliased explicitly. The defaults moved into
   BillingSourceService::syncDefaults() so they can be re-installed after a schema
   change instead of being trapped in a one-shot seed, and anything that still
   does not fit is named rather than dropped.

   Found by running the validator against the live database instead of trusting
   that an empty table meant "nothing to do".

2. The cheque lifecycle now posts.
   CheckLifecycleService had a correct state machine and zero journal entries, so
   a cheque moving desk → bank → collected, or bouncing, left no trace in the
   ledger at all.

   Each movement now posts through configurable account pointers:

     deposited   Dr شيكات تحت التحصيل  / Cr أوراق قبض
     collected   Dr البنك              / Cr شيكات تحت التحصيل
     bounced     Dr مدينون (شيكات مرتدة) / Cr شيكات تحت التحصيل
     endorsed    Dr الدائن             / Cr أوراق قبض
     paid        Dr أوراق دفع          / Cr البنك

   The bounce charge posts as its own entry so it can be waived without touching
   the restored debt. Re-presenting a bounced cheque moves it back to
   under_collection and posts the deposit leg again, so a second and third
   presentation each leave their own trail.

   Posting happens AFTER the status commit on purpose: a cheque physically moving
   to the bank must be recorded even when its accounts are unmapped, otherwise the
   paperwork and the system disagree. An unpostable move returns a warning.

   Also corrects a real error along the way: AccountCodes sends a cheque payment
   straight to the bank. Taking a post-dated cheque is not money in the bank — it
   is a note receivable until the bank collects it. The counter account is now a
   configurable pointer per payment method (treasury:method_check → أوراق قبض),
   so it is fixed from the screen rather than in code, and a header account there
   is refused with the pointer name to map.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 5e36f062
......@@ -5,6 +5,7 @@ namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Accounting\Services\Revenue\InstrumentPostingService;
final class CheckLifecycleService
{
......@@ -94,7 +95,39 @@ final class CheckLifecycleService
]);
$db->commit();
return ['success' => true, 'instrument_id' => $instrumentId];
// ── Ledger ──────────────────────────────────────────────────
// Posted after the commit, deliberately. A cheque moving from the desk
// to the bank is a real custody change and must be recorded even if the
// accounts are not mapped yet; rolling the status back because of an
// unmapped account would leave the physical cheque and the system
// disagreeing. An unpostable move returns a warning instead, and shows
// up on the diagnostics screen.
$posting = InstrumentPostingService::onStatusChange(
array_merge($instrument, $updateData),
$toStatus,
$options
);
if ($posting['posted'] && !empty($posting['journal_entry_id'])) {
try {
$db->update(
'negotiable_instruments',
['journal_entry_id' => (int) $posting['journal_entry_id']],
'id = ?',
[$instrumentId]
);
} catch (\Throwable $e) {
\App\Core\Logger::error('Instrument journal link failed: ' . $e->getMessage());
}
}
return [
'success' => true,
'instrument_id' => $instrumentId,
'journal_entry_id' => $posting['journal_entry_id'],
'posting_warning' => $posting['error'],
];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => $e->getMessage()];
......
......@@ -403,19 +403,37 @@ final class RevenuePostingEngine
return (int) $rule['debit_account_id'];
}
// auto_treasury — derive from the payment method / treasury like the legacy path.
// auto_treasury — the account the money actually landed in.
//
// A configurable pointer per payment method comes first, because the legacy
// constants get this wrong in a way that matters: a cheque is not money at
// the bank. Taking a post-dated cheque creates a note receivable, and it only
// becomes bank cash when the bank collects it — which is what the instrument
// lifecycle then posts. Mapping `treasury:method_check` to أوراق قبض fixes
// that from the screen instead of in code.
$method = $ctx['payment_method'] ?? 'cash';
$treasuryId = isset($ctx['treasury_id']) && $ctx['treasury_id'] ? (int) $ctx['treasury_id'] : null;
$code = AccountCodes::debitAccountForTreasury($method, $treasuryId);
$safeMethod = preg_match('/^[a-z_]{1,30}$/', $method) === 1 ? $method : 'cash';
$pointer = PostingRouter::accountFor('treasury:method_' . $safeMethod, null, 'collection');
if ($pointer !== null) {
return $pointer;
}
$code = AccountCodes::debitAccountForTreasury($method, $treasuryId);
$account = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
"SELECT id, is_header FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$code]
);
if (!$account) {
$errors[] = 'حساب النقدية ' . $code . ' غير موجود في دليل الحسابات';
return null;
}
if ((int) $account['is_header'] === 1) {
$errors[] = 'حساب النقدية ' . $code . ' حساب رئيسي — اربط «treasury:method_' . $safeMethod . '» بحساب فرعي';
return null;
}
return (int) $account['id'];
}
......
<?php
declare(strict_types=1);
/**
* Accounts and pointers for the cheque lifecycle, and the payment-method mapping.
*
* Two things are set up here.
*
* 1. THE PAYMENT-METHOD MAPPING. AccountCodes sends a cheque payment straight to
* the bank, which is wrong: taking a post-dated cheque does not put money in the
* bank, it creates a note receivable. That only becomes bank cash when the bank
* actually collects it. Mapping treasury:method_check to أوراق قبض fixes it, and
* because it is a pointer the club can re-point it from the screen.
*
* 2. THE INSTRUMENT LEGS. Each cheque movement — deposited, collected, bounced,
* endorsed, paid — is a ledger event with two accounts, all configurable.
*
* Existing accounts are reused wherever the chart already has them
* (1207 شيكات تحت التحصيل, 410511 مصاريف رفض شيكات); only genuinely missing leaves
* are created, and never under an account that already carries a balance.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$accId = function (string $code) use ($db): ?int {
$r = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_header = 0 AND is_active = 1",
[$code]
);
return $r ? (int) $r['id'] : null;
};
$ensureLeaf = function (string $code, string $nameAr, string $nameEn, string $parentCode)
use ($db, $now, $accId): ?int {
$existing = $accId($code);
if ($existing !== null) {
return $existing;
}
$parent = $db->selectOne(
"SELECT id, level, is_header, account_type, account_nature
FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$parentCode]
);
// Only hang a child off an existing header — converting a posting account
// into a header strands its balance.
if (!$parent || (int) $parent['is_header'] !== 1) {
return null;
}
$db->insert('chart_of_accounts', [
'account_code' => $code,
'name_ar' => $nameAr,
'name_en' => $nameEn,
'account_type' => $parent['account_type'],
'account_nature' => $parent['account_nature'],
'parent_id' => (int) $parent['id'],
'level' => ((int) $parent['level']) + 1,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'currency' => 'EGP',
'created_at' => $now,
'updated_at' => $now,
]);
return $accId($code);
};
// ── Accounts the lifecycle needs ─────────────────────────────────────
$notesReceivable = $ensureLeaf('120302004', 'أوراق قبض — أعضاء وعملاء', 'Notes Receivable — Members', '120302');
$bouncedDebtor = $ensureLeaf('120301005', 'شيكات مرتدة على الأعضاء', 'Bounced Cheques Receivable', '120301');
$underCollection = $accId('1207'); // شيكات تحت التحصيل — already exists
$bounceFeeRev = $accId('410511'); // مصاريف رفض شيكات — already exists
$bankAccount = $accId('12060201'); // البنك الأهلي
$cashAccount = $accId('12060101'); // الصندوق بالجنيه المصري
$notesPayable = $ensureLeaf('230602', 'أوراق دفع — موردون', 'Notes Payable — Suppliers', '2306');
// Endorsement hands the cheque to a creditor; default to trade payables.
$endorsement = $accId('230601002') ?? $notesPayable;
// ── Pointer streams ──────────────────────────────────────────────────
$pointers = [
// Where each payment method actually lands
['treasury:method_cash', 'النقدية — تحصيل نقدي', $cashAccount, 'collection', 'asset',
null],
['treasury:method_check', 'الشيكات المستلمة — أوراق قبض', $notesReceivable, 'collection', 'asset',
'الشيك ليس نقدية بالبنك — يُسجَّل ورقة قبض حتى يُحصَّل فعليًا'],
['treasury:method_visa', 'الفيزا — حساب البنك', $bankAccount, 'collection', 'asset', null],
['treasury:method_bank_transfer', 'التحويل البنكي — حساب البنك', $bankAccount, 'collection', 'asset', null],
// Instrument legs
['instrument:notes_receivable', 'أوراق القبض', $notesReceivable, 'collection', 'asset', null],
['instrument:under_collection_account', 'شيكات تحت التحصيل', $underCollection, 'collection', 'asset', null],
['instrument:bank_account', 'البنك — تحصيل الشيكات', $bankAccount, 'collection', 'asset', null],
['instrument:bounced_receivable', 'شيكات مرتدة على المدينين', $bouncedDebtor, 'collection', 'asset', null],
['instrument:endorsement_account', 'حساب التظهير للغير', $endorsement, 'collection', 'payable_offset', null],
['instrument:notes_payable', 'أوراق الدفع', $notesPayable, 'collection', 'expense', null],
['instrument:bounce_fee_revenue', 'إيراد مصاريف ارتداد الشيكات', $bounceFeeRev, 'collection', 'revenue', null],
];
foreach ($pointers as [$code, $nameAr, $accountId, $stage, $lineType, $note]) {
if ($accountId === null) {
continue; // the chart cannot support this leg here; diagnostics will show it unmapped
}
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if ($stream) {
$streamId = (int) $stream['id'];
} else {
$streamId = $db->insert('revenue_streams', [
'stream_code' => $code,
'name_ar' => $nameAr,
'source_module' => 'accounting',
'category' => 'treasury',
'wiring_status' => 'dispatches',
'wiring_note' => $note,
'is_system' => 1,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
if ($db->selectOne("SELECT id FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?", [$streamId, $stage])) {
continue;
}
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => 1,
'stage' => $stage,
'direction' => 'inflow',
'name_ar' => 'مؤشر حساب',
'debit_source' => 'fixed_account',
'debit_account_id' => $accountId,
'status' => 'active',
'effective_from' => '2000-01-01',
'notes' => 'مؤشر حساب — يحدد الحساب فقط',
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => $lineType,
'allocation_method' => 'remainder',
'percentage_base' => 'net_after_fixed',
'account_id' => $accountId,
'recognition_method' => 'immediate',
'description_ar' => $nameAr,
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
};
<?php
declare(strict_types=1);
use App\Modules\Accounting\Services\Revenue\BillingSourceService;
/**
* Install the default billing sources.
*
* Phase_104_001 ran and inserted nothing: its validation read information_schema
* with lower-case keys (column_name / data_type) while this server returns them
* upper case, so every column looked missing and every source was skipped. The
* validator now aliases the columns explicitly; this seed installs what that one
* should have.
*
* Anything that still does not fit is reported by name, not silently dropped.
*/
return function (\App\Core\Database $db): void {
\App\Core\App::getInstance()->setDb($db);
$result = BillingSourceService::syncDefaults();
echo " billing sources installed: {$result['created']}\n";
foreach ($result['skipped'] as $reason) {
echo " [skip] {$reason}\n";
}
};
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