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; ...@@ -5,6 +5,7 @@ namespace App\Modules\Accounting\Services;
use App\Core\App; use App\Core\App;
use App\Core\EventBus; use App\Core\EventBus;
use App\Modules\Accounting\Services\Revenue\InstrumentPostingService;
final class CheckLifecycleService final class CheckLifecycleService
{ {
...@@ -94,7 +95,39 @@ final class CheckLifecycleService ...@@ -94,7 +95,39 @@ final class CheckLifecycleService
]); ]);
$db->commit(); $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) { } catch (\Throwable $e) {
$db->rollBack(); $db->rollBack();
return ['success' => false, 'error' => $e->getMessage()]; return ['success' => false, 'error' => $e->getMessage()];
......
...@@ -43,8 +43,11 @@ final class BillingSourceService ...@@ -43,8 +43,11 @@ final class BillingSourceService
return ['ok' => false, 'error' => 'اسم الجدول غير صالح']; return ['ok' => false, 'error' => 'اسم الجدول غير صالح'];
} }
// Alias explicitly: information_schema returns COLUMN_NAME / DATA_TYPE in
// upper case on this server, so reading $c['column_name'] silently yields
// nothing and every column looks missing.
$cols = $db->select( $cols = $db->select(
"SELECT column_name, data_type FROM information_schema.columns "SELECT column_name AS col, data_type AS typ FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ?", WHERE table_schema = DATABASE() AND table_name = ?",
[$table] [$table]
); );
...@@ -54,7 +57,7 @@ final class BillingSourceService ...@@ -54,7 +57,7 @@ final class BillingSourceService
$types = []; $types = [];
foreach ($cols as $c) { foreach ($cols as $c) {
$types[strtolower((string) $c['column_name'])] = strtolower((string) $c['data_type']); $types[strtolower((string) $c['col'])] = strtolower((string) $c['typ']);
} }
$required = ['id_column', 'amount_column']; $required = ['id_column', 'amount_column'];
...@@ -517,6 +520,181 @@ final class BillingSourceService ...@@ -517,6 +520,181 @@ final class BillingSourceService
); );
} }
/**
* The sources the club gets out of the box.
*
* Kept here rather than inline in a seed so they can be re-synced from the
* settings screen after a schema change, and so a failed one reports why.
*/
public static function defaultSources(): array
{
return [
[
'code' => 'annual_subscription', 'name_ar' => 'الاشتراكات السنوية للأعضاء',
'source_table' => 'subscriptions', 'amount_column' => 'total_amount',
'reference_column' => 'financial_year', 'member_column' => 'member_id',
'name_column' => 'person_name',
'conditions' => [['column' => 'status', 'op' => 'in', 'value' => ['pending', 'overdue']]],
'writeback_payment_column' => 'payment_id',
'writeback_status_column' => 'status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'annual_subscription', 'stream_code' => 'payment:annual_subscription',
'allow_partial' => 1, 'sort_order' => 5,
],
[
'code' => 'sa_hourly_booking', 'name_ar' => 'حجوزات الملاعب بالساعة',
'description_ar' => 'حجوزات مسعّرة في وحدة الأنشطة الرياضية لم تُحصَّل بعد',
'source_table' => 'sa_bookings', 'amount_column' => 'total_amount',
'date_column' => 'booking_date', 'reference_column' => 'booking_number',
'name_column' => 'booker_name',
'conditions' => [
['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_payment_column' => 'payment_id', 'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'sort_order' => 10,
],
[
'code' => 'sa_subscription', 'name_ar' => 'اشتراكات الأنشطة الرياضية',
'source_table' => 'sa_subscriptions', 'amount_column' => 'final_amount',
'date_column' => 'period_start', 'reference_column' => 'subscription_number',
'player_column' => 'player_id',
'conditions' => [['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']]],
'writeback_payment_column' => 'payment_id', 'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'sports_subscription', 'stream_code' => 'payment:sports_subscription',
'allow_partial' => 1, 'sort_order' => 20,
],
[
'code' => 'sa_locker', 'name_ar' => 'إيجارات اللوكرات',
'source_table' => 'sa_locker_rentals', 'amount_column' => 'amount',
'date_column' => 'start_date', 'reference_column' => 'rental_number',
'player_column' => 'player_id',
'conditions' => [['column' => 'payment_status', 'op' => 'in', 'value' => ['unpaid', 'overdue']]],
'writeback_payment_column' => 'payment_id', 'writeback_receipt_column' => 'receipt_id',
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'other', 'stream_code' => 'payment:other',
'sort_order' => 30,
],
[
'code' => 'facility_reservation', 'name_ar' => 'حجوزات المرافق',
'source_table' => 'reservations', 'amount_column' => 'total_amount',
'date_column' => 'reservation_date', 'reference_column' => 'reservation_number',
'member_column' => 'member_id', 'player_column' => 'player_id', 'name_column' => 'booker_name',
'conditions' => [
['column' => 'payment_id', 'op' => 'is_null', 'value' => null],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_payment_column' => 'payment_id',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'sort_order' => 40,
],
[
'code' => 'private_match', 'name_ar' => 'المباريات الخاصة',
'source_table' => 'private_match_bookings', 'amount_column' => 'total_cost',
'date_column' => 'booking_date', 'member_column' => 'booked_by_member_id',
'name_column' => 'booked_by_name',
'conditions' => [
['column' => 'payment_status', 'op' => '!=', 'value' => 'paid'],
['column' => 'status', 'op' => 'not_in', 'value' => ['cancelled']],
],
'writeback_status_column' => 'payment_status', 'writeback_status_value' => 'paid',
'payment_type' => 'hourly_booking', 'stream_code' => 'payment:hourly_booking',
'allow_partial' => 1, 'sort_order' => 50,
],
[
'code' => 'rental_invoice', 'name_ar' => 'فواتير الإيجار',
'description_ar' => 'فواتير إيجار المحلات والوحدات المستحقة',
'source_table' => 'rental_invoices', 'amount_column' => 'total_amount',
'date_column' => 'due_date', 'reference_column' => 'invoice_number',
'name_column' => 'invoice_number',
'conditions' => [['column' => 'status', 'op' => 'not_in', 'value' => ['paid', 'cancelled']]],
'writeback_payment_column' => 'payment_id',
'writeback_status_column' => 'status', 'writeback_status_value' => 'paid',
'writeback_paid_at_column' => 'paid_at',
'payment_type' => 'other', 'stream_code' => 'rental:invoice',
'allow_partial' => 1, 'sort_order' => 60,
],
];
}
/**
* Install or repair the default sources.
*
* A source that does not fit this database is reported, not stored broken; one
* that already exists is left alone so local edits survive.
*
* @return array{created:int, skipped:array<int,string>}
*/
public static function syncDefaults(): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$created = 0;
$skipped = [];
foreach (self::defaultSources() as $s) {
if ($db->selectOne("SELECT id FROM billing_sources WHERE code = ?", [$s['code']])) {
continue;
}
$row = array_merge([
'id_column' => 'id', 'name_en' => null, 'description_ar' => null,
'date_column' => null, 'reference_column' => null,
'member_column' => null, 'player_column' => null, 'name_column' => null,
'writeback_payment_column' => null, 'writeback_receipt_column' => null,
'writeback_status_column' => null, 'writeback_status_value' => null,
'writeback_paid_at_column' => null,
'stream_code' => null, 'allow_partial' => 0, 'sort_order' => 100,
'conditions' => [],
], $s);
$check = self::validate($row);
if (!$check['ok']) {
$skipped[] = $s['code'] . ': ' . $check['error'];
continue;
}
$db->insert('billing_sources', [
'code' => $row['code'],
'name_ar' => $row['name_ar'],
'name_en' => $row['name_en'],
'description_ar' => $row['description_ar'],
'source_table' => $row['source_table'],
'id_column' => $row['id_column'],
'amount_column' => $row['amount_column'],
'date_column' => $row['date_column'],
'reference_column' => $row['reference_column'],
'member_column' => $row['member_column'],
'player_column' => $row['player_column'],
'name_column' => $row['name_column'],
'conditions' => json_encode($row['conditions'], JSON_UNESCAPED_UNICODE),
'writeback_payment_column' => $row['writeback_payment_column'],
'writeback_receipt_column' => $row['writeback_receipt_column'],
'writeback_status_column' => $row['writeback_status_column'],
'writeback_status_value' => $row['writeback_status_value'],
'writeback_paid_at_column' => $row['writeback_paid_at_column'],
'stream_code' => $row['stream_code'],
'payment_type' => $row['payment_type'],
'allow_partial' => $row['allow_partial'],
'validation_status' => 'ok',
'last_validated_at' => $now,
'sort_order' => $row['sort_order'],
'is_active' => 1,
'is_system' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$created++;
}
return ['created' => $created, 'skipped' => $skipped];
}
/** Columns of a table, for the settings picker. */ /** Columns of a table, for the settings picker. */
public static function tableColumns(string $table): array public static function tableColumns(string $table): array
{ {
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Services\JournalService;
/**
* Journal entries for the life of a cheque or promissory note.
*
* A cheque moves through several places before it is money, and each move is a
* ledger event that this system previously recorded nowhere:
*
* RECEIVED (from a member or tenant)
* in_hand أوراق قبض the cheque is a receivable, not cash
* under_collection شيكات تحت التحصيل handed to the bank
* collected البنك the bank paid us
* bounced back to the debtor plus any charge we levy
* endorsed to a third party the receivable leaves another way
*
* ISSUED (to a supplier)
* issued أوراق دفع we owe it
* paid البنك the bank paid it out
*
* Every account is a configurable pointer, so the club maps them once from the
* posting screen and never needs a code change to re-point one.
*
* Returned cheques: bouncing reverses the collection leg and puts the debt back on
* the payer, and the bounce charge posts separately so it can be waived without
* touching the debt. Re-presenting simply moves the instrument to under_collection
* again, which posts the deposit leg afresh — so a second and third presentation
* each leave their own trail rather than overwriting the first.
*/
final class InstrumentPostingService
{
/** stream code => [label, direction, counter pointer, allocation pointer] */
private const LEGS = [
// Received instruments
'instrument:deposited' => [
'label' => 'إيداع ورقة قبض برسم التحصيل',
'counter' => 'instrument:under_collection_account', // Dr شيكات تحت التحصيل
'line' => 'instrument:notes_receivable', // Cr أوراق قبض
'inflow' => true,
],
'instrument:collected' => [
'label' => 'تحصيل ورقة قبض',
'counter' => 'instrument:bank_account', // Dr البنك
'line' => 'instrument:under_collection_account', // Cr شيكات تحت التحصيل
'inflow' => true,
],
'instrument:bounced' => [
'label' => 'ارتداد ورقة قبض',
'counter' => 'instrument:bounced_receivable', // Dr مدينون (الدين رجع)
'line' => 'instrument:under_collection_account', // Cr شيكات تحت التحصيل
'inflow' => true,
],
'instrument:endorsed' => [
'label' => 'تظهير ورقة قبض',
'counter' => 'instrument:endorsement_account', // Dr المورد / الدائن
'line' => 'instrument:notes_receivable', // Cr أوراق قبض
'inflow' => true,
],
// Issued instruments
'instrument:paid' => [
'label' => 'سداد ورقة دفع',
'counter' => 'instrument:notes_payable', // Dr أوراق دفع
'line' => 'instrument:bank_account', // Cr البنك
'inflow' => true,
],
];
/** Which ledger leg a status change corresponds to, by instrument direction. */
private const STATUS_MAP = [
'received' => [
'under_collection' => 'instrument:deposited',
'collected' => 'instrument:collected',
'bounced' => 'instrument:bounced',
'endorsed' => 'instrument:endorsed',
],
'issued' => [
'paid' => 'instrument:paid',
'collected' => 'instrument:paid',
],
];
/**
* Post the ledger movement for a status change.
*
* Never throws into the caller: an instrument's status must still change even if
* its accounts are unmapped, otherwise the operator is stuck. Failures are logged
* and surfaced on the diagnostics screen instead.
*
* @return array{posted:bool, journal_entry_id:?int, error:?string}
*/
public static function onStatusChange(array $instrument, string $toStatus, array $options = []): array
{
try {
$direction = ($instrument['direction'] ?? 'received') === 'issued' ? 'issued' : 'received';
$stream = self::STATUS_MAP[$direction][$toStatus] ?? null;
if ($stream === null) {
// in_hand, cancelled and returned move custody, not value.
return ['posted' => false, 'journal_entry_id' => null, 'error' => null];
}
$amount = number_format((float) ($instrument['amount'] ?? 0), 2, '.', '');
if (bccomp($amount, '0.01', 2) < 0) {
return ['posted' => false, 'journal_entry_id' => null, 'error' => 'قيمة الورقة صفر'];
}
$leg = self::LEGS[$stream];
// The bank leg follows the instrument's own bank account when it has one,
// so a club with several banks does not need a rule per bank.
$counterId = self::resolveLegAccount($leg['counter'], $instrument);
$lineId = self::resolveLegAccount($leg['line'], $instrument);
if ($counterId === null || $lineId === null) {
$missing = $counterId === null ? $leg['counter'] : $leg['line'];
return [
'posted' => false,
'journal_entry_id' => null,
'error' => 'حساب غير مربوط: ' . $missing . ' — اربطه من شاشة توزيع الإيرادات',
];
}
if ($counterId === $lineId) {
return [
'posted' => false,
'journal_entry_id' => null,
'error' => 'طرفا القيد نفس الحساب — راجع ربط ' . $leg['counter'] . ' و ' . $leg['line'],
];
}
$number = $instrument['instrument_number'] ?? ('#' . ($instrument['id'] ?? ''));
$desc = $leg['label'] . ' رقم ' . $number;
$memberId = !empty($instrument['member_id']) ? (int) $instrument['member_id'] : null;
$date = $options['date'] ?? date('Y-m-d');
$result = JournalService::createEntry([
'entry_date' => $date,
'description_ar' => $desc,
'description_en' => 'Instrument ' . $toStatus . ' — ' . $number,
'reference_type' => 'instrument_' . $toStatus,
'reference_id' => (int) ($instrument['id'] ?? 0),
'reference_number' => (string) $number,
'source_module' => 'accounting',
'is_auto_generated' => 1,
], [
[
'account_id' => $counterId,
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $desc,
'member_id' => $memberId,
],
[
'account_id' => $lineId,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => $desc,
'member_id' => $toStatus === 'bounced' ? $memberId : null,
],
], true);
if (!$result['success']) {
return ['posted' => false, 'journal_entry_id' => null, 'error' => $result['error'] ?? 'فشل القيد'];
}
$entryId = (int) $result['journal_entry_id'];
// A bounce charge is separate from the debt so it can be waived on its own.
if ($toStatus === 'bounced') {
self::postBounceCharge($instrument, $options, $date);
}
return ['posted' => true, 'journal_entry_id' => $entryId, 'error' => null];
} catch (\Throwable $e) {
Logger::error('Instrument posting failed', [
'instrument' => $instrument['id'] ?? null,
'status' => $toStatus,
'error' => $e->getMessage(),
]);
return ['posted' => false, 'journal_entry_id' => null, 'error' => $e->getMessage()];
}
}
/**
* The fee the club levies when a cheque comes back.
*
* Separate entry on purpose: the returned amount is the member's debt restored,
* while the charge is the club's income. Waiving the charge later must not
* disturb the debt, and vice versa.
*/
private static function postBounceCharge(array $instrument, array $options, string $date): void
{
$fee = $options['bounce_fee'] ?? null;
if ($fee === null || $fee === '') {
return;
}
$fee = number_format((float) $fee, 2, '.', '');
if (bccomp($fee, '0.01', 2) < 0) {
return;
}
$debtor = self::resolveLegAccount('instrument:bounced_receivable', $instrument);
$revenue = PostingRouter::accountFor('instrument:bounce_fee_revenue', '410511', 'collection');
if ($debtor === null || $revenue === null || $debtor === $revenue) {
Logger::error('Bounce fee not posted — accounts unresolved', [
'instrument' => $instrument['id'] ?? null,
]);
return;
}
$number = $instrument['instrument_number'] ?? ('#' . ($instrument['id'] ?? ''));
$desc = 'مصاريف ارتداد شيك رقم ' . $number;
JournalService::createEntry([
'entry_date' => $date,
'description_ar' => $desc,
'reference_type' => 'instrument_bounce_fee',
'reference_id' => (int) ($instrument['id'] ?? 0),
'reference_number' => (string) $number,
'source_module' => 'accounting',
'is_auto_generated' => 1,
], [
[
'account_id' => $debtor,
'debit' => $fee,
'credit' => '0.00',
'description_ar' => $desc,
'member_id' => !empty($instrument['member_id']) ? (int) $instrument['member_id'] : null,
],
[
'account_id' => $revenue,
'debit' => '0.00',
'credit' => $fee,
'description_ar' => $desc,
],
], true);
}
/**
* Resolve one leg, preferring the instrument's own bank account for the bank leg
* so multi-bank clubs need one mapping, not one per bank.
*/
private static function resolveLegAccount(string $pointer, array $instrument): ?int
{
if ($pointer === 'instrument:bank_account' && !empty($instrument['bank_account_id'])) {
$db = App::getInstance()->db();
$bank = $db->selectOne(
"SELECT gl_account_id FROM bank_accounts WHERE id = ?",
[(int) $instrument['bank_account_id']]
);
if ($bank && !empty($bank['gl_account_id'])) {
$acc = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE id = ? AND is_header = 0 AND is_active = 1",
[(int) $bank['gl_account_id']]
);
if ($acc) {
return (int) $acc['id'];
}
}
}
return PostingRouter::accountFor($pointer, null, 'collection');
}
/** Reverse the entries a status change produced — used when a change is undone. */
public static function reverseFor(int $instrumentId, string $status, string $reason): void
{
$entry = \App\Modules\Accounting\Models\JournalEntry::findByReference('instrument_' . $status, $instrumentId);
if ($entry && $entry->isPosted()) {
JournalService::reverseEntry((int) $entry->id, $reason);
}
}
/** The pointers this service needs, for the diagnostics screen. */
public static function requiredPointers(): array
{
return [
'instrument:notes_receivable' => 'أوراق القبض (شيكات مستلمة في اليد)',
'instrument:under_collection_account' => 'شيكات تحت التحصيل (مودعة بالبنك)',
'instrument:bank_account' => 'البنك (عند التحصيل الفعلي)',
'instrument:bounced_receivable' => 'مدينون — شيكات مرتدة',
'instrument:endorsement_account' => 'حساب التظهير للغير',
'instrument:notes_payable' => 'أوراق الدفع (شيكات صادرة)',
'instrument:bounce_fee_revenue' => 'إيراد مصاريف ارتداد الشيكات',
];
}
}
...@@ -403,19 +403,37 @@ final class RevenuePostingEngine ...@@ -403,19 +403,37 @@ final class RevenuePostingEngine
return (int) $rule['debit_account_id']; 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'; $method = $ctx['payment_method'] ?? 'cash';
$treasuryId = isset($ctx['treasury_id']) && $ctx['treasury_id'] ? (int) $ctx['treasury_id'] : null; $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( $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] [$code]
); );
if (!$account) { if (!$account) {
$errors[] = 'حساب النقدية ' . $code . ' غير موجود في دليل الحسابات'; $errors[] = 'حساب النقدية ' . $code . ' غير موجود في دليل الحسابات';
return null; return null;
} }
if ((int) $account['is_header'] === 1) {
$errors[] = 'حساب النقدية ' . $code . ' حساب رئيسي — اربط «treasury:method_' . $safeMethod . '» بحساب فرعي';
return null;
}
return (int) $account['id']; 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