Commit fb3097a2 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): extend posting engine to the full accounting cycle

Generalises the revenue engine from "collection" to every stage a document
posts at, and routes all 26 auto-posting paths through it.

Two new dimensions on a rule:

  stage      accrual | collection | payment | refund | writeoff | transfer
  direction  inflow  → counter account DEBITED, allocation lines CREDITED
             outflow → allocation lines DEBITED, counter account CREDITED

So the same allocation maths now drives revenue, expense, receivable and
payable postings. Contra-revenue is always a debit regardless of direction.

Where the amounts are computed elsewhere and only the accounts need to be
configurable — payroll components, treasury legs, COGS, rental legs — a
second mechanism (PostingRouter::accountFor) resolves a configurable account
pointer instead of forcing those through the allocator. Both are edited from
the same screen.

Dead posting paths fixed. Each of these targeted a header account, which
JournalService refuses, and the callers only Logger::error — so they have
been failing invisibly:

- 230601 الموردون is a header → the ENTIRE procurement cycle (vendor invoice,
  vendor payment, return-to-vendor) could never post. Now 230601002.
- 310103 حصة الشركة في التأمينات did not exist at all → payroll dropped the
  employer insurance line, then a balancing fallback silently increased the
  bank credit to force the entry to balance, misstating cash. The account is
  created, and an imbalance now refuses to post and reports instead.
- 230804 جاري مصلحة الضرائب is a header → rental VAT could never post.
  Now 23080404 ضريبة القيمة المضافة.
- AccountCodes::INPUT_TAX resolved to 120408 مدينو بيع أوراق مالية, an
  unrelated account. Input VAT now posts to 12041106.
- Member write-off debited MISCELLANEOUS_REVENUE. A bad debt is an expense;
  it now posts to 3328 ديون معدومة.
- $result['entry_id'] is never returned by JournalService (the key is
  journal_entry_id), so rental invoices, treasury settlements and treasury
  deposits never linked back to their journal entry.
- SUB_TREASURY_CASH points at 12060102 الصندوق بالدولار, the USD box. Left
  deliberately unmapped and surfaced on the diagnostics page so finance picks
  the right EGP account rather than having one guessed for them.

Accruals now also create the accounts_receivable sub-ledger row alongside the
GL entry, which is why that table was empty against 970,592.67 EGP of
scheduled instalments.

Verified against a full clone of the production schema and chart of accounts
in a throwaway database: all six stages post balanced entries, VAT 14%
inclusive on 1140 yields 1000 revenue + 140 tax, a five-line split (two fixed
+ two percentage + remainder) balances to the piastre, and a 12,000 annual
subscription produces exactly 12 monthly deferral rows summing to 12,000 with
the recognition run posting the current period. 27 allocation unit tests pass.

Seeded rules reproduce existing behaviour except where that behaviour was a
silent failure. Unconfigured stages still fall through to the legacy path.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent dc305901
...@@ -46,42 +46,55 @@ class RevenueMappingController extends Controller ...@@ -46,42 +46,55 @@ class RevenueMappingController extends Controller
} }
$streams = $db->select( $streams = $db->select(
"SELECT s.*, "SELECT s.* FROM revenue_streams s
r.id AS rule_id,
r.version AS rule_version,
r.effective_from,
r.notes AS rule_notes,
r.tax_profile_id,
tp.name_ar AS tax_name,
tp.rate AS tax_rate,
(SELECT COUNT(*) FROM revenue_posting_rule_lines l
WHERE l.rule_id = r.id AND l.is_active = 1) AS line_count
FROM revenue_streams s
LEFT JOIN revenue_posting_rules r
ON r.stream_id = s.id
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
LEFT JOIN revenue_tax_profiles tp ON tp.id = r.tax_profile_id
WHERE " . implode(' AND ', $where) . " WHERE " . implode(' AND ', $where) . "
ORDER BY s.category ASC, s.name_ar ASC", ORDER BY s.category ASC, s.name_ar ASC",
$params $params
); );
// Attach the target accounts + live volume so the list is decision-ready. // Attach every configured stage, its lines, and live volume so the list is
// decision-ready without opening each stream.
foreach ($streams as &$s) { foreach ($streams as &$s) {
$s['lines'] = []; $rules = $db->select(
if (!empty($s['rule_id'])) { "SELECT r.*, tp.name_ar AS tax_name, tp.rate AS tax_rate
$s['lines'] = $db->select( FROM revenue_posting_rules r
LEFT JOIN revenue_tax_profiles tp ON tp.id = r.tax_profile_id
WHERE r.stream_id = ?
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
ORDER BY FIELD(r.stage, 'accrual','collection','payment','refund','writeoff','transfer')",
[(int) $s['id']]
);
$s['stages'] = [];
foreach ($rules as $r) {
$r['lines'] = $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name, coa.is_header "SELECT l.*, coa.account_code, coa.name_ar AS account_name, coa.is_header
FROM revenue_posting_rule_lines l FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE l.rule_id = ? AND l.is_active = 1 WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order ASC", ORDER BY l.sort_order ASC",
[(int) $s['rule_id']] [(int) $r['id']]
); );
$s['stages'][$r['stage']] = $r;
} }
// The primary stage drives the summary row.
$primary = $s['stages']['collection']
?? $s['stages']['accrual']
?? $s['stages']['payment']
?? ($s['stages'] ? reset($s['stages']) : null);
$s['rule_id'] = $primary['id'] ?? null;
$s['rule_version'] = $primary['version'] ?? null;
$s['primary_stage'] = $primary['stage'] ?? null;
$s['tax_profile_id'] = $primary['tax_profile_id'] ?? null;
$s['tax_name'] = $primary['tax_name'] ?? null;
$s['tax_rate'] = $primary['tax_rate'] ?? null;
$s['lines'] = $primary['lines'] ?? [];
$s['line_count'] = count($s['lines']);
$s['volume'] = ['n' => 0, 'total' => '0.00']; $s['volume'] = ['n' => 0, 'total' => '0.00'];
if ($s['source_module'] === 'payments' && !empty($s['source_key'])) { if ($s['source_module'] === 'payments' && !empty($s['source_key'])) {
$v = $db->selectOne( $v = $db->selectOne(
...@@ -92,15 +105,17 @@ class RevenueMappingController extends Controller ...@@ -92,15 +105,17 @@ class RevenueMappingController extends Controller
$s['volume'] = ['n' => (int) ($v['n'] ?? 0), 'total' => (string) ($v['total'] ?? '0.00')]; $s['volume'] = ['n' => (int) ($v['n'] ?? 0), 'total' => (string) ($v['total'] ?? '0.00')];
} }
$s['is_mapped'] = !empty($s['rule_id']); $s['is_mapped'] = !empty($s['stages']);
$s['is_catchall'] = false; $s['is_catchall'] = false;
$s['has_header'] = false; $s['has_header'] = false;
foreach ($s['lines'] as $l) { foreach ($s['stages'] as $st) {
if ($l['account_code'] === '410515') { foreach ($st['lines'] as $l) {
$s['is_catchall'] = true; if ($l['account_code'] === '410515') {
} $s['is_catchall'] = true;
if ((int) $l['is_header'] === 1) { }
$s['has_header'] = true; if ((int) $l['is_header'] === 1) {
$s['has_header'] = true;
}
} }
} }
} }
...@@ -108,6 +123,8 @@ class RevenueMappingController extends Controller ...@@ -108,6 +123,8 @@ class RevenueMappingController extends Controller
if ($status === 'unmapped') { if ($status === 'unmapped') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => !$s['is_mapped'])); $streams = array_values(array_filter($streams, static fn(array $s): bool => !$s['is_mapped']));
} elseif ($status === 'broken') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => $s['has_header']));
} elseif ($status === 'catchall') { } elseif ($status === 'catchall') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => $s['is_catchall'])); $streams = array_values(array_filter($streams, static fn(array $s): bool => $s['is_catchall']));
} elseif ($status === 'split') { } elseif ($status === 'split') {
...@@ -140,14 +157,23 @@ class RevenueMappingController extends Controller ...@@ -140,14 +157,23 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود'); return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
} }
$rule = RevenuePostingEngine::resolveRule((int) $id, date('Y-m-d')); // Which stage are we editing? Default to whatever this stream already has.
$configured = RevenuePostingEngine::configuredStages((int) $id);
$stage = (string) $request->get('stage', '');
if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) {
$stage = $configured[0] ?? self::defaultStageFor($stream);
}
$rule = RevenuePostingEngine::resolveRule((int) $id, date('Y-m-d'), ['stage' => $stage]);
$lines = []; $lines = [];
if ($rule) { if ($rule) {
$lines = $db->select( $lines = $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name "SELECT l.*, coa.account_code, coa.name_ar AS account_name,
rec.account_code AS recognized_code, rec.name_ar AS recognized_name
FROM revenue_posting_rule_lines l FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id JOIN chart_of_accounts coa ON coa.id = l.account_id
LEFT JOIN chart_of_accounts rec ON rec.id = l.recognized_account_id
WHERE l.rule_id = ? AND l.is_active = 1 WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order ASC", ORDER BY l.sort_order ASC",
[(int) $rule['id']] [(int) $rule['id']]
...@@ -158,10 +184,10 @@ class RevenueMappingController extends Controller ...@@ -158,10 +184,10 @@ class RevenueMappingController extends Controller
"SELECT r.*, e.full_name_ar AS activated_by_name "SELECT r.*, e.full_name_ar AS activated_by_name
FROM revenue_posting_rules r FROM revenue_posting_rules r
LEFT JOIN employees e ON e.id = r.activated_by LEFT JOIN employees e ON e.id = r.activated_by
WHERE r.stream_id = ? WHERE r.stream_id = ? AND r.stage = ?
ORDER BY r.version DESC ORDER BY r.version DESC
LIMIT 20", LIMIT 20",
[(int) $id] [(int) $id, $stage]
); );
$debitLabel = ''; $debitLabel = '';
...@@ -181,12 +207,26 @@ class RevenueMappingController extends Controller ...@@ -181,12 +207,26 @@ class RevenueMappingController extends Controller
'lines' => $lines, 'lines' => $lines,
'history' => $history, 'history' => $history,
'debitLabel' => $debitLabel, 'debitLabel' => $debitLabel,
'stage' => $stage,
'stages' => RevenuePostingEngine::STAGE_LABELS,
'configured' => $configured,
'taxProfiles' => $db->select("SELECT * FROM revenue_tax_profiles WHERE is_active = 1 ORDER BY tax_code"), 'taxProfiles' => $db->select("SELECT * FROM revenue_tax_profiles WHERE is_active = 1 ORDER BY tax_code"),
'costCenters' => $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code"), 'costCenters' => $db->select("SELECT id, code, name_ar FROM cost_centers WHERE is_active = 1 ORDER BY code"),
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"), 'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
]); ]);
} }
/** A sensible first stage to offer for a stream that has none configured. */
private static function defaultStageFor(array $stream): string
{
return match ($stream['category'] ?? 'other') {
'procurement', 'payroll' => 'payment',
'treasury' => 'transfer',
'writeoff' => 'writeoff',
default => 'collection',
};
}
/** /**
* Saving never edits a posted-against rule in place — it supersedes it with a * Saving never edits a posted-against rule in place — it supersedes it with a
* new version. Entries already in the ledger keep pointing at the version that * new version. Entries already in the ledger keep pointing at the version that
...@@ -203,10 +243,21 @@ class RevenueMappingController extends Controller ...@@ -203,10 +243,21 @@ class RevenueMappingController extends Controller
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود'); return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
} }
$stage = (string) $request->post('stage', 'collection');
if (!\in_array($stage, RevenuePostingEngine::STAGES, true)) {
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError('مرحلة قيد غير معروفة');
}
$back = '/accounting/revenue-mapping/' . $streamId . '/edit?stage=' . $stage;
$direction = (string) $request->post('direction', 'inflow');
if (!\in_array($direction, ['inflow', 'outflow'], true)) {
$direction = 'inflow';
}
$payload = $this->parseLines($request); $payload = $this->parseLines($request);
if (!empty($payload['errors'])) { if (!empty($payload['errors'])) {
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit') return $this->redirect($back)->withError(implode(' — ', $payload['errors']));
->withError(implode(' — ', $payload['errors']));
} }
$effectiveFrom = (string) $request->post('effective_from', date('Y-m-d')); $effectiveFrom = (string) $request->post('effective_from', date('Y-m-d'));
...@@ -214,9 +265,12 @@ class RevenueMappingController extends Controller ...@@ -214,9 +265,12 @@ class RevenueMappingController extends Controller
$effectiveFrom = date('Y-m-d'); $effectiveFrom = date('Y-m-d');
} }
$current = RevenuePostingEngine::resolveRule($streamId, date('Y-m-d')); $current = RevenuePostingEngine::resolveRule($streamId, date('Y-m-d'), ['stage' => $stage]);
$nextVersion = 1; $nextVersion = 1;
$maxRow = $db->selectOne("SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ?", [$streamId]); $maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?",
[$streamId, $stage]
);
if ($maxRow && $maxRow['v'] !== null) { if ($maxRow && $maxRow['v'] !== null) {
$nextVersion = ((int) $maxRow['v']) + 1; $nextVersion = ((int) $maxRow['v']) + 1;
} }
...@@ -229,13 +283,15 @@ class RevenueMappingController extends Controller ...@@ -229,13 +283,15 @@ class RevenueMappingController extends Controller
$taxProfileId = ($taxProfileId !== null && $taxProfileId !== '') ? (int) $taxProfileId : null; $taxProfileId = ($taxProfileId !== null && $taxProfileId !== '') ? (int) $taxProfileId : null;
$debitSource = (string) $request->post('debit_source', 'auto_treasury'); $debitSource = (string) $request->post('debit_source', 'auto_treasury');
if (!\in_array($debitSource, ['auto_treasury', 'fixed_account', 'accounts_receivable', 'accounts_payable'], true)) {
$debitSource = 'auto_treasury';
}
$debitAccountId = $request->post('debit_account_id'); $debitAccountId = $request->post('debit_account_id');
$debitAccountId = ($debitAccountId !== null && $debitAccountId !== '') ? (int) $debitAccountId : null; $debitAccountId = ($debitAccountId !== null && $debitAccountId !== '') ? (int) $debitAccountId : null;
if ($debitSource === 'auto_treasury') { if ($debitSource === 'auto_treasury') {
$debitAccountId = null; $debitAccountId = null;
} elseif ($debitAccountId === null) { } elseif ($debitAccountId === null) {
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit') return $this->redirect($back)->withError('اختر الحساب المقابل عند استخدام حساب محدد');
->withError('اختر الحساب المدين عند استخدام حساب ثابت');
} }
$costCenterId = $request->post('cost_center_id'); $costCenterId = $request->post('cost_center_id');
...@@ -252,6 +308,8 @@ class RevenueMappingController extends Controller ...@@ -252,6 +308,8 @@ class RevenueMappingController extends Controller
$ruleId = $db->insert('revenue_posting_rules', [ $ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId, 'stream_id' => $streamId,
'version' => $nextVersion, 'version' => $nextVersion,
'stage' => $stage,
'direction' => $direction,
'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion), 'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion),
'branch_id' => $branchId, 'branch_id' => $branchId,
'payment_method' => $paymentMethod, 'payment_method' => $paymentMethod,
...@@ -312,12 +370,14 @@ class RevenueMappingController extends Controller ...@@ -312,12 +370,14 @@ class RevenueMappingController extends Controller
$db->commit(); $db->commit();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->rollBack(); $db->rollBack();
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit') return $this->redirect($back)->withError('فشل حفظ القاعدة: ' . $e->getMessage());
->withError('فشل حفظ القاعدة: ' . $e->getMessage());
} }
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit') $stageLabel = RevenuePostingEngine::STAGE_LABELS[$stage] ?? $stage;
->withSuccess('تم حفظ الإصدار ' . $nextVersion . ' وتفعيله اعتبارًا من ' . $effectiveFrom);
return $this->redirect($back)->withSuccess(
'تم حفظ الإصدار ' . $nextVersion . ' لمرحلة «' . $stageLabel . '» وتفعيله اعتبارًا من ' . $effectiveFrom
);
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
...@@ -350,6 +410,10 @@ class RevenueMappingController extends Controller ...@@ -350,6 +410,10 @@ class RevenueMappingController extends Controller
$taxProfile $taxProfile
); );
$direction = (string) $request->input('direction', 'inflow');
$alloc['direction'] = \in_array($direction, ['inflow', 'outflow'], true) ? $direction : 'inflow';
$alloc['stage'] = (string) $request->input('stage', 'collection');
// Decorate with account labels for display. // Decorate with account labels for display.
$db = App::getInstance()->db(); $db = App::getInstance()->db();
foreach ($alloc['allocations'] as &$a) { foreach ($alloc['allocations'] as &$a) {
...@@ -568,24 +632,63 @@ class RevenueMappingController extends Controller ...@@ -568,24 +632,63 @@ class RevenueMappingController extends Controller
// Legacy hardcoded constants that point at a header or missing account. // Legacy hardcoded constants that point at a header or missing account.
$legacyBroken = []; $legacyBroken = [];
$legacyMap = [ $legacyMap = [
'ACCOUNTS_RECEIVABLE' => ['120301', 'حساب المدينين المستخدم في قيود الغرامات والأقساط'], 'ACCOUNTS_RECEIVABLE' => ['120301', 'المدينون — قيود الغرامات والأقساط والتحويلات', 'ar:control'],
'TAX_PAYABLE' => ['230804', 'حساب الضرائب المستخدم في قيود الإيجارات والمرتبات'], 'ACCOUNTS_PAYABLE' => ['230601', 'الدائنون — دورة المشتريات بالكامل', 'procurement:payable'],
'INPUT_TAX' => ['120408', 'حساب ضريبة المدخلات'], 'TAX_PAYABLE' => ['230804', 'الضرائب — قيود الإيجارات والمرتبات', 'rental:output_tax'],
'DEFERRED_REVENUE' => ['230809', 'حساب الإيرادات المقدمة'], 'INPUT_TAX' => ['120408', 'ضريبة المدخلات — فواتير الموردين', 'procurement:input_tax'],
'COGS' => ['3172', 'حساب تكلفة البضاعة المباعة'], 'INSURANCE_EXPENSE' => ['310103', 'حصة صاحب العمل في التأمينات — قيد المرتبات', 'payroll:employer_insurance'],
'DEPRECIATION' => ['3316', 'الإهلاكات', null],
'SUB_TREASURY_CASH' => ['12060102', 'الخزنة الفرعية — تسويات الأنشطة', 'treasury:sub_cash'],
'DEFERRED_REVENUE' => ['230809', 'الإيرادات المقدمة', null],
'COGS' => ['3172', 'تكلفة البضاعة المباعة', 'sales:cogs'],
]; ];
foreach ($legacyMap as $const => [$code, $label]) {
foreach ($legacyMap as $const => [$code, $label, $streamCode]) {
$acc = $db->selectOne( $acc = $db->selectOne(
"SELECT account_code, name_ar, is_header, is_active FROM chart_of_accounts WHERE account_code = ?", "SELECT account_code, name_ar, is_header, is_active, currency
FROM chart_of_accounts WHERE account_code = ?",
[$code] [$code]
); );
$issue = null;
if (!$acc) { if (!$acc) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'الحساب غير موجود', 'name' => '—']; $issue = 'الحساب غير موجود';
} elseif ((int) $acc['is_header'] === 1) { } elseif ((int) $acc['is_header'] === 1) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'حساب رئيسي — الترحيل يفشل صامتًا', 'name' => $acc['name_ar']]; $issue = 'حساب رئيسي — الترحيل يفشل صامتًا';
} elseif ((int) $acc['is_active'] === 0) { } elseif ((int) $acc['is_active'] === 0) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'حساب غير نشط', 'name' => $acc['name_ar']]; $issue = 'حساب غير نشط';
} elseif (($acc['currency'] ?? 'EGP') !== 'EGP' && $acc['currency'] !== '') {
$issue = 'حساب بعملة ' . $acc['currency'] . ' — لا يصلح للقيود بالجنيه';
}
if ($issue === null) {
continue;
}
// Has the engine been pointed somewhere valid instead?
$covered = false;
if ($streamCode !== null) {
$covered = $db->selectOne(
"SELECT 1 AS ok
FROM revenue_streams s
JOIN revenue_posting_rules r ON r.stream_id = s.id AND r.status = 'active'
JOIN revenue_posting_rule_lines l ON l.rule_id = r.id AND l.is_active = 1
JOIN chart_of_accounts coa ON coa.id = l.account_id AND coa.is_header = 0 AND coa.is_active = 1
WHERE s.stream_code = ?
LIMIT 1",
[$streamCode]
) !== null;
} }
$legacyBroken[] = [
'const' => $const,
'code' => $code,
'label' => $label,
'issue' => $issue,
'name' => $acc['name_ar'] ?? '—',
'stream' => $streamCode,
'covered' => $covered,
];
} }
$failures = $db->select( $failures = $db->select(
...@@ -781,13 +884,33 @@ class RevenueMappingController extends Controller ...@@ -781,13 +884,33 @@ class RevenueMappingController extends Controller
"SELECT COALESCE(SUM(amount), 0) AS total FROM revenue_recognition_schedules WHERE status = 'pending'" "SELECT COALESCE(SUM(amount), 0) AS total FROM revenue_recognition_schedules WHERE status = 'pending'"
); );
// Any active rule line that cannot actually be posted to.
$broken = $db->selectOne(
"SELECT COUNT(DISTINCT r.stream_id) AS n
FROM revenue_posting_rule_lines l
JOIN revenue_posting_rules r ON r.id = l.rule_id AND r.status = 'active'
JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE coa.is_header = 1 OR coa.is_active = 0"
);
$byStage = $db->select(
"SELECT stage, COUNT(DISTINCT stream_id) AS n
FROM revenue_posting_rules
WHERE status = 'active'
AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE())
GROUP BY stage"
);
return [ return [
'total' => (int) ($total['n'] ?? 0), 'total' => (int) ($total['n'] ?? 0),
'mapped' => (int) ($mapped['n'] ?? 0), 'mapped' => (int) ($mapped['n'] ?? 0),
'unmapped' => max(0, ((int) ($total['n'] ?? 0)) - ((int) ($mapped['n'] ?? 0))), 'unmapped' => max(0, ((int) ($total['n'] ?? 0)) - ((int) ($mapped['n'] ?? 0))),
'split' => (int) ($split['n'] ?? 0), 'split' => (int) ($split['n'] ?? 0),
'catch_all' => (int) ($catchAll['n'] ?? 0), 'catch_all' => (int) ($catchAll['n'] ?? 0),
'broken' => (int) ($broken['n'] ?? 0),
'deferred' => (string) ($deferred['total'] ?? '0.00'), 'deferred' => (string) ($deferred['total'] ?? '0.00'),
'by_stage' => array_column($byStage, 'n', 'stage'),
]; ];
} }
......
...@@ -6,6 +6,9 @@ namespace App\Modules\Accounting\Services; ...@@ -6,6 +6,9 @@ namespace App\Modules\Accounting\Services;
use App\Core\App; use App\Core\App;
use App\Core\Logger; use App\Core\Logger;
use App\Modules\Accounting\AccountCodes; use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
use App\Modules\Accounting\Services\Revenue\RevenueRecognitionService;
use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
/** /**
* Auto-posting journal entries from other modules. * Auto-posting journal entries from other modules.
...@@ -142,28 +145,6 @@ final class AccountingIntegrationService ...@@ -142,28 +145,6 @@ final class AccountingIntegrationService
return false; // CLI context without a bound connection return false; // CLI context without a bound connection
} }
// The engine's tables may not exist yet on an un-migrated environment.
$hasTable = $db->selectOne(
"SELECT 1 AS ok FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'revenue_posting_rules'"
);
if (!$hasTable) {
return false;
}
$streamCode = \App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry::codeForPaymentType($type);
$stream = $db->selectOne(
"SELECT id FROM revenue_streams WHERE stream_code = ? AND is_active = 1",
[$streamCode]
);
if (!$stream) {
return false;
}
if (!\App\Modules\Accounting\Services\Revenue\RevenuePostingEngine::isConfigured((int) $stream['id'])) {
return false;
}
$payment = $paymentId > 0 ? $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]) : null; $payment = $paymentId > 0 ? $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]) : null;
$receiptNumber = ''; $receiptNumber = '';
...@@ -182,34 +163,28 @@ final class AccountingIntegrationService ...@@ -182,34 +163,28 @@ final class AccountingIntegrationService
$treasuryId = (int) $payment['treasury_id']; $treasuryId = (int) $payment['treasury_id'];
} }
$result = \App\Modules\Accounting\Services\Revenue\RevenuePostingEngine::post($streamCode, [ $routed = PostingRouter::attempt(
'amount' => $amount, RevenueStreamRegistry::codeForPaymentType($type),
'entry_date' => $payment['payment_date'] ?? date('Y-m-d'), 'collection',
'payment_method' => $method, [
'treasury_id' => $treasuryId, 'amount' => $amount,
'branch_id' => $data['branch_id'] ?? null, 'entry_date' => $payment['payment_date'] ?? date('Y-m-d'),
'member_id' => $memberId, 'payment_method' => $method,
'reference_type' => 'payment', 'treasury_id' => $treasuryId,
'reference_id' => $paymentId, 'branch_id' => $data['branch_id'] ?? null,
'reference_number' => $receiptNumber, 'member_id' => $memberId,
'source_module' => 'payments', 'reference_type' => 'payment',
'description_ar' => $description, 'reference_id' => $paymentId,
'description_en' => 'Payment collection — ' . $type, 'reference_number' => $receiptNumber,
'period_months' => $data['period_months'] ?? null, 'source_module' => 'payments',
]); 'description_ar' => $description,
'description_en' => 'Payment collection — ' . $type,
if (!$result['success']) { 'period_months' => $data['period_months'] ?? null,
// The rule exists but could not produce a valid entry. Do NOT fall back — 'service_start_date' => $data['service_start_date'] ?? null,
// a silent legacy post would hide a real configuration error. ]
Logger::error('Revenue posting rule failed', [ );
'payment_id' => $paymentId,
'stream' => $streamCode,
'error' => $result['error'] ?? '',
]);
return true;
}
return true; return $routed['handled'];
} }
/** /**
...@@ -264,19 +239,11 @@ final class AccountingIntegrationService ...@@ -264,19 +239,11 @@ final class AccountingIntegrationService
); );
$totalCost = (string) ($costRow['total_cost'] ?? '0.00'); $totalCost = (string) ($costRow['total_cost'] ?? '0.00');
$salesRevenue = self::getAccountByCode(AccountCodes::SALES_REVENUE); $cogsAccountId = PostingRouter::accountFor('sales:cogs', AccountCodes::COGS, 'accrual');
$cashAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); $inventoryAccountId = PostingRouter::accountFor('sales:inventory', AccountCodes::INVENTORY, 'accrual');
$cogsAccount = self::getAccountByCode(AccountCodes::COGS);
$inventoryAccount = self::getAccountByCode(AccountCodes::INVENTORY);
if (!$salesRevenue || !$cashAccount) { // The revenue side rides on payment.completed; COGS needs its own entry.
return; if ($cogsAccountId !== null && $inventoryAccountId !== null && bccomp($totalCost, '0.00', 2) > 0) {
}
$description = 'مبيعات فاتورة رقم ' . $invoiceNumber;
// Revenue entry is handled by payment.completed, but COGS needs its own entry
if ($cogsAccount && $inventoryAccount && bccomp($totalCost, '0.00', 2) > 0) {
$cogsResult = JournalService::createEntry([ $cogsResult = JournalService::createEntry([
'entry_date' => $sale['sale_date'] ?? date('Y-m-d'), 'entry_date' => $sale['sale_date'] ?? date('Y-m-d'),
'description_ar' => 'تكلفة بضاعة مباعة — فاتورة ' . $invoiceNumber, 'description_ar' => 'تكلفة بضاعة مباعة — فاتورة ' . $invoiceNumber,
...@@ -288,13 +255,13 @@ final class AccountingIntegrationService ...@@ -288,13 +255,13 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], [ ], [
[ [
'account_id' => (int) $cogsAccount['id'], 'account_id' => $cogsAccountId,
'debit' => $totalCost, 'debit' => $totalCost,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'تكلفة بضاعة مباعة', 'description_ar' => 'تكلفة بضاعة مباعة',
], ],
[ [
'account_id' => (int) $inventoryAccount['id'], 'account_id' => $inventoryAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $totalCost, 'credit' => $totalCost,
'description_ar' => 'خصم من المخزون', 'description_ar' => 'خصم من المخزون',
...@@ -369,14 +336,21 @@ final class AccountingIntegrationService ...@@ -369,14 +336,21 @@ final class AccountingIntegrationService
$erInsurance = $componentMap['social_insurance_employer'] ?? '0.00'; $erInsurance = $componentMap['social_insurance_employer'] ?? '0.00';
$totalInsurance = bcadd($empInsurance, $erInsurance, 2); $totalInsurance = bcadd($empInsurance, $erInsurance, 2);
$salaryExpense = self::getAccountByCode(AccountCodes::SALARY_EXPENSE); // Each leg is a configurable account pointer, so finance can re-map payroll
$insuranceExpense = self::getAccountByCode(AccountCodes::INSURANCE_EXPENSE); // without a code change. The fallbacks are the legacy chart codes — except
$bankAccount = self::getAccountByCode(AccountCodes::CASH_AT_BANK); // the tax leg, which must not use the header 230804.
$insurancePayable = self::getAccountByCode(AccountCodes::INSURANCE_PAYABLE); $salaryExpenseId = PostingRouter::accountFor('payroll:gross_salary', AccountCodes::SALARY_EXPENSE, 'payment');
$taxPayable = self::getAccountByCode(AccountCodes::TAX_PAYABLE); $insuranceExpenseId = PostingRouter::accountFor('payroll:employer_insurance', '310103', 'payment');
$bankAccountId = PostingRouter::accountFor('payroll:net_paid', AccountCodes::CASH_AT_BANK, 'payment');
if (!$salaryExpense || !$bankAccount) { $insurancePayableId = PostingRouter::accountFor('payroll:insurance_payable', AccountCodes::INSURANCE_PAYABLE,'payment');
Logger::error("Payroll auto-post failed: core accounts not found"); $taxPayableId = PostingRouter::accountFor('payroll:tax_withheld', '23080403', 'payment');
if ($salaryExpenseId === null || $bankAccountId === null) {
Logger::error('Payroll auto-post failed: core accounts unresolved', [
'payroll_run_id' => $payrollRunId,
'salary' => $salaryExpenseId,
'bank' => $bankAccountId,
]);
return; return;
} }
...@@ -387,16 +361,23 @@ final class AccountingIntegrationService ...@@ -387,16 +361,23 @@ final class AccountingIntegrationService
// Dr. Salary Expense (gross) // Dr. Salary Expense (gross)
$lines[] = [ $lines[] = [
'account_id' => (int) $salaryExpense['id'], 'account_id' => $salaryExpenseId,
'debit' => $grossSalary, 'debit' => $grossSalary,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'مصروفات رواتب — ' . $periodName, 'description_ar' => 'مصروفات رواتب — ' . $periodName,
]; ];
// Dr. Insurance Expense (employer share) // Dr. Insurance Expense (employer share)
if ($insuranceExpense && bccomp($erInsurance, '0.00', 2) > 0) { if (bccomp($erInsurance, '0.00', 2) > 0) {
if ($insuranceExpenseId === null) {
Logger::error('Payroll: employer insurance account unresolved — entry not posted', [
'payroll_run_id' => $payrollRunId,
'amount' => $erInsurance,
]);
return;
}
$lines[] = [ $lines[] = [
'account_id' => (int) $insuranceExpense['id'], 'account_id' => $insuranceExpenseId,
'debit' => $erInsurance, 'debit' => $erInsurance,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'حصة صاحب العمل في التأمينات — ' . $periodName, 'description_ar' => 'حصة صاحب العمل في التأمينات — ' . $periodName,
...@@ -405,16 +386,20 @@ final class AccountingIntegrationService ...@@ -405,16 +386,20 @@ final class AccountingIntegrationService
// Cr. Bank (net salary) // Cr. Bank (net salary)
$lines[] = [ $lines[] = [
'account_id' => (int) $bankAccount['id'], 'account_id' => $bankAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $netSalary, 'credit' => $netSalary,
'description_ar' => 'صرف رواتب — ' . $periodName, 'description_ar' => 'صرف رواتب — ' . $periodName,
]; ];
// Cr. Insurance Payable // Cr. Insurance Payable
if ($insurancePayable && bccomp($totalInsurance, '0.00', 2) > 0) { if (bccomp($totalInsurance, '0.00', 2) > 0) {
if ($insurancePayableId === null) {
Logger::error('Payroll: insurance payable account unresolved — entry not posted', ['payroll_run_id' => $payrollRunId]);
return;
}
$lines[] = [ $lines[] = [
'account_id' => (int) $insurancePayable['id'], 'account_id' => $insurancePayableId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $totalInsurance, 'credit' => $totalInsurance,
'description_ar' => 'تأمينات مستحقة — ' . $periodName, 'description_ar' => 'تأمينات مستحقة — ' . $periodName,
...@@ -422,16 +407,20 @@ final class AccountingIntegrationService ...@@ -422,16 +407,20 @@ final class AccountingIntegrationService
} }
// Cr. Tax Payable // Cr. Tax Payable
if ($taxPayable && bccomp($totalTax, '0.00', 2) > 0) { if (bccomp($totalTax, '0.00', 2) > 0) {
if ($taxPayableId === null) {
Logger::error('Payroll: tax payable account unresolved — entry not posted', ['payroll_run_id' => $payrollRunId]);
return;
}
$lines[] = [ $lines[] = [
'account_id' => (int) $taxPayable['id'], 'account_id' => $taxPayableId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $totalTax, 'credit' => $totalTax,
'description_ar' => 'ضرائب مستحقة — ' . $periodName, 'description_ar' => 'ضرائب مستحقة — ' . $periodName,
]; ];
} }
// Verify double-entry balance before posting // Verify double-entry balance before posting.
$totalDebit = '0.00'; $totalDebit = '0.00';
$totalCredit = '0.00'; $totalCredit = '0.00';
foreach ($lines as $l) { foreach ($lines as $l) {
...@@ -439,21 +428,26 @@ final class AccountingIntegrationService ...@@ -439,21 +428,26 @@ final class AccountingIntegrationService
$totalCredit = bcadd($totalCredit, (string) $l['credit'], 2); $totalCredit = bcadd($totalCredit, (string) $l['credit'], 2);
} }
// If imbalanced due to rounding or missing components, adjust // An imbalance here means the payroll components do not reconcile to the run
// totals. Silently plugging the bank line — which is what this used to do —
// misstates cash and hides the real problem. Refuse and report instead.
$diff = bcsub($totalDebit, $totalCredit, 2); $diff = bcsub($totalDebit, $totalCredit, 2);
if (bccomp($diff, '0.00', 2) !== 0) { if (bccomp($diff, '0.00', 2) !== 0) {
// Adjust bank line to balance Logger::error('Payroll entry does not balance — not posted', [
foreach ($lines as &$l) { 'payroll_run_id' => $payrollRunId,
if ((int) $l['account_id'] === (int) $bankAccount['id']) { 'gross' => $grossSalary,
$l['credit'] = bcadd((string) $l['credit'], $diff, 2); 'net' => $netSalary,
break; 'tax' => $totalTax,
} 'insurance' => $totalInsurance,
} 'debit' => $totalDebit,
unset($l); 'credit' => $totalCredit,
'difference' => $diff,
]);
return;
} }
$result = JournalService::createEntry([ $result = JournalService::createEntry([
'entry_date' => $run['payment_date'] ?? date('Y-m-d'), 'entry_date' => $run['paid_at'] ?? $run['payment_date'] ?? date('Y-m-d'),
'description_ar' => 'قيد رواتب — ' . $periodName, 'description_ar' => 'قيد رواتب — ' . $periodName,
'description_en' => 'Payroll entry — ' . $periodName, 'description_en' => 'Payroll entry — ' . $periodName,
'reference_type' => 'payroll', 'reference_type' => 'payroll',
...@@ -558,7 +552,28 @@ final class AccountingIntegrationService ...@@ -558,7 +552,28 @@ final class AccountingIntegrationService
return; return;
} }
// Create AR entry // `fines` has no imposed_date column — created_at is the imposition date.
$imposedDate = isset($fine['created_at']) ? substr((string) $fine['created_at'], 0, 10) : date('Y-m-d');
$routed = PostingRouter::attempt('fine:imposed', 'accrual', [
'amount' => $amount,
'entry_date' => $imposedDate,
'member_id' => $memberId,
'reference_type' => 'fine',
'reference_id' => $fineId,
'source_module' => 'fines',
'description_ar' => 'استحقاق غرامة — عضو رقم ' . $memberId,
]);
if ($routed['handled']) {
if ($routed['journal_entry_id'] !== null) {
self::upsertReceivable($memberId, 'fine', $fineId, $amount, $imposedDate,
date('Y-m-d', strtotime($imposedDate . ' +30 days')), 'غرامة مخالفة', $routed['journal_entry_id']);
}
return;
}
// Legacy path.
$arAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE); $arAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$fineRevenue = self::getAccountByCode(AccountCodes::FINE_REVENUE); $fineRevenue = self::getAccountByCode(AccountCodes::FINE_REVENUE);
...@@ -632,6 +647,28 @@ final class AccountingIntegrationService ...@@ -632,6 +647,28 @@ final class AccountingIntegrationService
return; return;
} }
$planDate = !empty($plan['created_at']) ? substr((string) $plan['created_at'], 0, 10) : date('Y-m-d');
$dueDate = $plan['end_date'] ?? date('Y-m-d', strtotime('+12 months'));
$routed = PostingRouter::attempt('installment:plan', 'accrual', [
'amount' => $totalAmount,
'entry_date' => $planDate,
'member_id' => $memberId,
'reference_type' => 'installment_plan',
'reference_id' => $planId,
'source_module' => 'installments',
'description_ar' => 'استحقاق أقساط عضوية — عضو رقم ' . $memberId,
]);
if ($routed['handled']) {
if ($routed['journal_entry_id'] !== null) {
self::upsertReceivable($memberId, 'installment', $planId, $totalAmount,
$planDate, $dueDate, 'أقساط عضوية', $routed['journal_entry_id']);
}
return;
}
// Legacy path.
$arAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE); $arAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$membershipRevenue = self::getAccountByCode(AccountCodes::INSTALLMENT_REVENUE); $membershipRevenue = self::getAccountByCode(AccountCodes::INSTALLMENT_REVENUE);
...@@ -749,6 +786,20 @@ final class AccountingIntegrationService ...@@ -749,6 +786,20 @@ final class AccountingIntegrationService
return; return;
} }
$description = 'مرتجع مبيعات — فاتورة ' . ($sale['invoice_number'] ?? '') . ' — إشعار ' . $refundNumber;
$routed = PostingRouter::attempt('sales:refund', 'refund', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'payment_method' => $data['payment_method'] ?? 'cash',
'reference_type' => 'sale_refund',
'reference_id' => $refundId,
'reference_number' => $refundNumber,
'source_module' => 'sales',
'description_ar' => $description,
]);
if ($routed['handled']) return;
$salesRevenue = self::getAccountByCode(AccountCodes::SALES_REVENUE); $salesRevenue = self::getAccountByCode(AccountCodes::SALES_REVENUE);
$cashAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); $cashAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
...@@ -757,8 +808,6 @@ final class AccountingIntegrationService ...@@ -757,8 +808,6 @@ final class AccountingIntegrationService
return; return;
} }
$description = 'مرتجع مبيعات — فاتورة ' . ($sale['invoice_number'] ?? '') . ' — إشعار ' . $refundNumber;
$result = JournalService::createEntry([ $result = JournalService::createEntry([
'entry_date' => date('Y-m-d'), 'entry_date' => date('Y-m-d'),
'description_ar' => $description, 'description_ar' => $description,
...@@ -917,14 +966,22 @@ final class AccountingIntegrationService ...@@ -917,14 +966,22 @@ final class AccountingIntegrationService
$payMethod = $payment ? ($payment['payment_method'] ?? 'cash') : 'cash'; $payMethod = $payment ? ($payment['payment_method'] ?? 'cash') : 'cash';
$debitCode = AccountCodes::debitAccountForMethod($payMethod); $debitCode = AccountCodes::debitAccountForMethod($payMethod);
$debitAccount = self::getAccountByCode($debitCode); // A rental invoice carries four independently computed amounts, so the
$rentalRevenue = self::getAccountByCode(AccountCodes::RENTAL_REVENUE); // allocator does not apply — but each leg is still a configurable pointer.
$serviceRevenue = self::getAccountByCode(AccountCodes::SERVICE_REVENUE); // AccountCodes::TAX_PAYABLE is the header 230804 and could never post; VAT
$taxPayable = self::getAccountByCode(AccountCodes::TAX_PAYABLE); // now resolves to the postable 23080404.
$fineRevenue = self::getAccountByCode(AccountCodes::FINE_REVENUE); $debitAccountId = PostingRouter::accountFor('rental:cash_in', $debitCode, 'collection');
$rentalRevenueId = PostingRouter::accountFor('rental:base', AccountCodes::RENTAL_REVENUE, 'collection');
if (!$debitAccount || !$rentalRevenue || !$taxPayable) { $serviceRevenueId = PostingRouter::accountFor('rental:utilities', AccountCodes::SERVICE_REVENUE, 'collection');
Logger::error('Rental invoice paid: missing accounts', ['invoice_id' => $invoiceId]); $vatPayableId = PostingRouter::accountFor('rental:output_tax', '23080404', 'collection');
$fineRevenueId = PostingRouter::accountFor('rental:late_fee', AccountCodes::FINE_REVENUE, 'collection');
if ($debitAccountId === null || $rentalRevenueId === null) {
Logger::error('Rental invoice paid: core accounts unresolved', ['invoice_id' => $invoiceId]);
return;
}
if (bccomp($vatAmount, '0.00', 2) > 0 && $vatPayableId === null) {
Logger::error('Rental invoice paid: VAT account unresolved — entry not posted', ['invoice_id' => $invoiceId]);
return; return;
} }
...@@ -932,7 +989,7 @@ final class AccountingIntegrationService ...@@ -932,7 +989,7 @@ final class AccountingIntegrationService
// Dr. Cash/Bank — full amount received // Dr. Cash/Bank — full amount received
$lines[] = [ $lines[] = [
'account_id' => (int) $debitAccount['id'], 'account_id' => $debitAccountId,
'debit' => $totalAmount, 'debit' => $totalAmount,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'تحصيل فاتورة إيجار ' . $invoiceNum, 'description_ar' => 'تحصيل فاتورة إيجار ' . $invoiceNum,
...@@ -941,7 +998,7 @@ final class AccountingIntegrationService ...@@ -941,7 +998,7 @@ final class AccountingIntegrationService
// Cr. Rental Revenue (base) // Cr. Rental Revenue (base)
if (bccomp($baseAmount, '0.00', 2) > 0) { if (bccomp($baseAmount, '0.00', 2) > 0) {
$lines[] = [ $lines[] = [
'account_id' => (int) $rentalRevenue['id'], 'account_id' => $rentalRevenueId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $baseAmount, 'credit' => $baseAmount,
'description_ar' => 'إيجار — ' . $invoiceNum . ' — عقد ' . $contractNum, 'description_ar' => 'إيجار — ' . $invoiceNum . ' — عقد ' . $contractNum,
...@@ -949,19 +1006,19 @@ final class AccountingIntegrationService ...@@ -949,19 +1006,19 @@ final class AccountingIntegrationService
} }
// Cr. Service Revenue (utilities) // Cr. Service Revenue (utilities)
if (bccomp($utilsAmount, '0.00', 2) > 0 && $serviceRevenue) { if (bccomp($utilsAmount, '0.00', 2) > 0 && $serviceRevenueId !== null) {
$lines[] = [ $lines[] = [
'account_id' => (int) $serviceRevenue['id'], 'account_id' => $serviceRevenueId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $utilsAmount, 'credit' => $utilsAmount,
'description_ar' => 'مرافق إيجار — ' . $invoiceNum, 'description_ar' => 'مرافق إيجار — ' . $invoiceNum,
]; ];
} }
// Cr. Tax Payable (VAT) // Cr. VAT Payable — a liability owed to the tax authority, not revenue
if (bccomp($vatAmount, '0.00', 2) > 0) { if (bccomp($vatAmount, '0.00', 2) > 0) {
$lines[] = [ $lines[] = [
'account_id' => (int) $taxPayable['id'], 'account_id' => $vatPayableId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $vatAmount, 'credit' => $vatAmount,
'description_ar' => 'ضريبة قيمة مضافة — ' . $invoiceNum, 'description_ar' => 'ضريبة قيمة مضافة — ' . $invoiceNum,
...@@ -969,9 +1026,9 @@ final class AccountingIntegrationService ...@@ -969,9 +1026,9 @@ final class AccountingIntegrationService
} }
// Cr. Fine Revenue (late fee) // Cr. Fine Revenue (late fee)
if (bccomp($lateFee, '0.00', 2) > 0 && $fineRevenue) { if (bccomp($lateFee, '0.00', 2) > 0 && $fineRevenueId !== null) {
$lines[] = [ $lines[] = [
'account_id' => (int) $fineRevenue['id'], 'account_id' => $fineRevenueId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $lateFee, 'credit' => $lateFee,
'description_ar' => 'غرامة تأخير إيجار — ' . $invoiceNum, 'description_ar' => 'غرامة تأخير إيجار — ' . $invoiceNum,
...@@ -988,8 +1045,8 @@ final class AccountingIntegrationService ...@@ -988,8 +1045,8 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], $lines, true); ], $lines, true);
if ($result['success'] && !empty($result['entry_id'])) { if ($result['success'] && !empty($result['journal_entry_id'])) {
$db->update('rental_invoices', ['journal_entry_id' => (int) $result['entry_id']], 'id = ?', [$invoiceId]); $db->update('rental_invoices', ['journal_entry_id' => (int) $result['journal_entry_id']], 'id = ?', [$invoiceId]);
} else { } else {
Logger::error('Rental invoice journal entry failed', ['invoice_id' => $invoiceId, 'error' => $result['error'] ?? '']); Logger::error('Rental invoice journal entry failed', ['invoice_id' => $invoiceId, 'error' => $result['error'] ?? '']);
} }
...@@ -1027,13 +1084,18 @@ final class AccountingIntegrationService ...@@ -1027,13 +1084,18 @@ final class AccountingIntegrationService
return; return;
} }
$inventoryAccount = self::getAccountByCode(AccountCodes::INVENTORY); // 230601 الموردون and 230804 جاري مصلحة الضرائب are BOTH header accounts, so
$taxAccount = self::getAccountByCode(AccountCodes::TAX_PAYABLE); // the legacy codes here could never post. These pointers resolve to the
$apAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_PAYABLE); // postable leaves and stay re-mappable from the UI.
$inventoryAccountId = PostingRouter::accountFor('procurement:inventory_receipt', AccountCodes::INVENTORY, 'accrual');
$inputTaxAccountId = PostingRouter::accountFor('procurement:input_tax', '12041106', 'accrual');
$apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
if (!$inventoryAccount || !$apAccount) { if ($inventoryAccountId === null || $apAccountId === null) {
Logger::error("Vendor invoice auto-post failed: core accounts not found", [ Logger::error("Vendor invoice auto-post failed: core accounts unresolved", [
'invoice_id' => $invoiceId, 'invoice_id' => $invoiceId,
'inventory' => $inventoryAccountId,
'payable' => $apAccountId,
]); ]);
return; return;
} }
...@@ -1045,29 +1107,36 @@ final class AccountingIntegrationService ...@@ -1045,29 +1107,36 @@ final class AccountingIntegrationService
// Dr. Inventory (subtotal) // Dr. Inventory (subtotal)
if (bccomp($subtotal, '0.00', 2) > 0) { if (bccomp($subtotal, '0.00', 2) > 0) {
$lines[] = [ $lines[] = [
'account_id' => (int) $inventoryAccount['id'], 'account_id' => $inventoryAccountId,
'debit' => $subtotal, 'debit' => $subtotal,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'مخزون — فاتورة مورد ' . $invoiceNumber, 'description_ar' => 'مخزون — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
]; ];
} }
// Dr. Tax Receivable (Input VAT) // Dr. Input VAT — recoverable, an asset. Never the output-tax liability.
if ($taxAccount && bccomp($taxAmount, '0.00', 2) > 0) { if (bccomp($taxAmount, '0.00', 2) > 0) {
if ($inputTaxAccountId === null) {
Logger::error('Vendor invoice: input tax account unresolved — entry not posted', ['invoice_id' => $invoiceId]);
return;
}
$lines[] = [ $lines[] = [
'account_id' => (int) $taxAccount['id'], 'account_id' => $inputTaxAccountId,
'debit' => $taxAmount, 'debit' => $taxAmount,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'ضريبة مدخلات — فاتورة مورد ' . $invoiceNumber, 'description_ar' => 'ضريبة مدخلات — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
]; ];
} }
// Cr. Accounts Payable (total) // Cr. Accounts Payable (total)
$lines[] = [ $lines[] = [
'account_id' => (int) $apAccount['id'], 'account_id' => $apAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $totalAmount, 'credit' => $totalAmount,
'description_ar' => 'دائنون — فاتورة مورد ' . $invoiceNumber, 'description_ar' => 'دائنون — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
]; ];
$result = JournalService::createEntry([ $result = JournalService::createEntry([
...@@ -1146,14 +1215,19 @@ final class AccountingIntegrationService ...@@ -1146,14 +1215,19 @@ final class AccountingIntegrationService
return; return;
} }
$apAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_PAYABLE);
$cashBankCode = \in_array($paymentMethod, ['bank_transfer', 'check', 'wire'], true) $cashBankCode = \in_array($paymentMethod, ['bank_transfer', 'check', 'wire'], true)
? AccountCodes::CASH_AT_BANK ? AccountCodes::CASH_AT_BANK
: AccountCodes::CASH_ON_HAND; : AccountCodes::CASH_ON_HAND;
$cashBankAccount = self::getAccountByCode($cashBankCode);
if (!$apAccount || !$cashBankAccount) { $apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
Logger::error("Vendor payment auto-post failed: accounts not found", ['payment_id' => $paymentId]); $cashBankAccountId = PostingRouter::accountFor('procurement:cash_out', $cashBankCode, 'payment');
if ($apAccountId === null || $cashBankAccountId === null) {
Logger::error("Vendor payment auto-post failed: accounts unresolved", [
'payment_id' => $paymentId,
'payable' => $apAccountId,
'cash' => $cashBankAccountId,
]);
return; return;
} }
...@@ -1175,13 +1249,14 @@ final class AccountingIntegrationService ...@@ -1175,13 +1249,14 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], [ ], [
[ [
'account_id' => (int) $apAccount['id'], 'account_id' => $apAccountId,
'debit' => $amount, 'debit' => $amount,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'تسديد دائنون — ' . $paymentNumber, 'description_ar' => 'تسديد دائنون — ' . $paymentNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
], ],
[ [
'account_id' => (int) $cashBankAccount['id'], 'account_id' => $cashBankAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $amount, 'credit' => $amount,
'description_ar' => 'صرف نقدي/بنكي — ' . $paymentNumber, 'description_ar' => 'صرف نقدي/بنكي — ' . $paymentNumber,
...@@ -1298,11 +1373,11 @@ final class AccountingIntegrationService ...@@ -1298,11 +1373,11 @@ final class AccountingIntegrationService
$rtvNumber = $rtv['rtv_number'] ?? ''; $rtvNumber = $rtv['rtv_number'] ?? '';
$apAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_PAYABLE); $apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
$inventoryAccount = self::getAccountByCode(AccountCodes::INVENTORY); $inventoryAccountId = PostingRouter::accountFor('procurement:inventory_receipt', AccountCodes::INVENTORY, 'accrual');
if (!$apAccount || !$inventoryAccount) { if ($apAccountId === null || $inventoryAccountId === null) {
Logger::error("RTV auto-post failed: accounts not found", ['rtv_id' => $rtvId]); Logger::error("RTV auto-post failed: accounts unresolved", ['rtv_id' => $rtvId]);
return; return;
} }
...@@ -1319,13 +1394,14 @@ final class AccountingIntegrationService ...@@ -1319,13 +1394,14 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], [ ], [
[ [
'account_id' => (int) $apAccount['id'], 'account_id' => $apAccountId,
'debit' => $totalAmount, 'debit' => $totalAmount,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => 'تخفيض دائنون — مرتجع مورد ' . $rtvNumber, 'description_ar' => 'تخفيض دائنون — مرتجع مورد ' . $rtvNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
], ],
[ [
'account_id' => (int) $inventoryAccount['id'], 'account_id' => $inventoryAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $totalAmount, 'credit' => $totalAmount,
'description_ar' => 'خصم من المخزون — مرتجع مورد ' . $rtvNumber, 'description_ar' => 'خصم من المخزون — مرتجع مورد ' . $rtvNumber,
...@@ -1382,6 +1458,56 @@ final class AccountingIntegrationService ...@@ -1382,6 +1458,56 @@ final class AccountingIntegrationService
); );
} }
/**
* Record (or refresh) the open receivable behind an accrual.
*
* The sub-ledger only means anything if it is created alongside the GL entry,
* so this is called with the journal entry id the accrual produced.
*/
private static function upsertReceivable(
int $memberId,
string $documentType,
int $documentId,
string $amount,
string $documentDate,
string $dueDate,
string $description,
int $journalEntryId
): void {
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$existing = $db->selectOne(
"SELECT id FROM accounts_receivable WHERE document_type = ? AND document_id = ?",
[$documentType, $documentId]
);
if ($existing) {
$db->update('accounts_receivable', [
'total_amount' => $amount,
'journal_entry_id' => $journalEntryId,
'updated_at' => $now,
], '`id` = ?', [(int) $existing['id']]);
return;
}
$db->insert('accounts_receivable', [
'member_id' => $memberId > 0 ? $memberId : null,
'document_type' => $documentType,
'document_id' => $documentId,
'document_date' => $documentDate,
'due_date' => $dueDate,
'description_ar' => $description,
'total_amount' => $amount,
'paid_amount' => '0.00',
'balance' => $amount,
'status' => 'pending',
'journal_entry_id' => $journalEntryId,
'created_at' => $now,
'updated_at' => $now,
]);
}
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// FACILITY & POOL ACCESS // FACILITY & POOL ACCESS
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
...@@ -1395,14 +1521,25 @@ final class AccountingIntegrationService ...@@ -1395,14 +1521,25 @@ final class AccountingIntegrationService
$entryId = (int) ($data['entry_id'] ?? 0); $entryId = (int) ($data['entry_id'] ?? 0);
$bookerType = $data['booker_type'] ?? 'guest'; $bookerType = $data['booker_type'] ?? 'guest';
$revenueCode = AccountCodes::FACILITY_ENTRY_REVENUE; $description = 'إيرادات دخول مرفق #' . $facilityId . ' — ' . ($bookerType === 'member' ? 'عضو' : 'ضيف');
$debitCode = AccountCodes::CASH_ON_HAND;
$debitAccount = self::getAccountByCode($debitCode);
$revenueAccount = self::getAccountByCode($revenueCode);
if (!$debitAccount || !$revenueAccount) return; $routed = PostingRouter::attempt('facility:entry', 'collection', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'payment_method' => $data['payment_method'] ?? 'cash',
'treasury_id' => $data['treasury_id'] ?? null,
'member_id' => $data['member_id'] ?? null,
'reference_type' => 'facility_entry',
'reference_id' => $entryId,
'source_module' => 'facilities',
'description_ar' => $description,
]);
if ($routed['handled']) return;
$description = 'إيرادات دخول مرفق #' . $facilityId . ' — ' . ($bookerType === 'member' ? 'عضو' : 'ضيف'); $debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
$revenueAccount = self::getAccountByCode(AccountCodes::FACILITY_ENTRY_REVENUE);
if (!$debitAccount || !$revenueAccount) return;
JournalService::createEntry([ JournalService::createEntry([
'entry_date' => date('Y-m-d'), 'entry_date' => date('Y-m-d'),
...@@ -1424,6 +1561,19 @@ final class AccountingIntegrationService ...@@ -1424,6 +1561,19 @@ final class AccountingIntegrationService
$entryId = (int) ($data['entry_id'] ?? 0); $entryId = (int) ($data['entry_id'] ?? 0);
$routed = PostingRouter::attempt('carnet:guest_entry', 'collection', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'payment_method' => $data['payment_method'] ?? 'cash',
'treasury_id' => $data['treasury_id'] ?? null,
'member_id' => $data['member_id'] ?? null,
'reference_type' => 'guest_entry',
'reference_id' => $entryId,
'source_module' => 'carnets',
'description_ar' => 'إيرادات دخول ضيف (كارنيه دعوات) #' . $entryId,
]);
if ($routed['handled']) return;
$debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); $debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
$revenueAccount = self::getAccountByCode(AccountCodes::GUEST_ENTRY_REVENUE); $revenueAccount = self::getAccountByCode(AccountCodes::GUEST_ENTRY_REVENUE);
...@@ -1450,6 +1600,18 @@ final class AccountingIntegrationService ...@@ -1450,6 +1600,18 @@ final class AccountingIntegrationService
$tournamentId = (int) ($data['tournament_id'] ?? 0); $tournamentId = (int) ($data['tournament_id'] ?? 0);
$playerId = (int) ($data['player_id'] ?? 0); $playerId = (int) ($data['player_id'] ?? 0);
$routed = PostingRouter::attempt('tournament:fee', 'collection', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'payment_method' => $data['payment_method'] ?? 'cash',
'treasury_id' => $data['treasury_id'] ?? null,
'reference_type' => 'tournament_fee',
'reference_id' => $tournamentId,
'source_module' => 'tournaments',
'description_ar' => 'رسوم اشتراك بطولة #' . $tournamentId . ' — لاعب #' . $playerId,
]);
if ($routed['handled']) return;
$debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); $debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND);
$revenueAccount = self::getAccountByCode(AccountCodes::TOURNAMENT_FEE_REVENUE); $revenueAccount = self::getAccountByCode(AccountCodes::TOURNAMENT_FEE_REVENUE);
...@@ -1499,6 +1661,32 @@ final class AccountingIntegrationService ...@@ -1499,6 +1661,32 @@ final class AccountingIntegrationService
default => 'رسوم فصل/تحويل', default => 'رسوم فصل/تحويل',
}; };
// Each transfer type is its own stream so finance can split them apart —
// today all three land in the same miscellaneous account.
$streamCode = match ($transferType) {
'divorce' => 'transfer:divorce_fee',
'death' => 'transfer:death_fee',
default => 'transfer:separation_fee',
};
$routed = PostingRouter::attempt($streamCode, 'accrual', [
'amount' => $feeAmount,
'entry_date' => date('Y-m-d'),
'member_id' => $sourceMemberId,
'reference_type' => 'transfer_request',
'reference_id' => $transferId,
'source_module' => 'transfers',
'description_ar' => $typeLabel . ' — طلب تحويل #' . $transferId,
]);
if ($routed['handled']) {
if ($routed['journal_entry_id'] !== null && $sourceMemberId > 0) {
self::upsertReceivable($sourceMemberId, 'transfer', $transferId, $feeAmount,
date('Y-m-d'), date('Y-m-d', strtotime('+30 days')), $typeLabel, $routed['journal_entry_id']);
}
return;
}
$debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE); $debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$creditAccount = self::getAccountByCode($creditCode); $creditAccount = self::getAccountByCode($creditCode);
if (!$debitAccount || !$creditAccount) return; if (!$debitAccount || !$creditAccount) return;
...@@ -1529,12 +1717,30 @@ final class AccountingIntegrationService ...@@ -1529,12 +1717,30 @@ final class AccountingIntegrationService
return; return;
} }
$description = 'رسوم تنازل عن العضوية — طلب #' . $waiverId;
$routed = PostingRouter::attempt('waiver:fee', 'accrual', [
'amount' => $feeAmount,
'entry_date' => date('Y-m-d'),
'member_id' => $sourceMemberId,
'reference_type' => 'waiver_request',
'reference_id' => $waiverId,
'source_module' => 'waivers',
'description_ar' => $description,
]);
if ($routed['handled']) {
if ($routed['journal_entry_id'] !== null && $sourceMemberId > 0) {
self::upsertReceivable($sourceMemberId, 'waiver', $waiverId, $feeAmount,
date('Y-m-d'), date('Y-m-d', strtotime('+30 days')), 'رسوم تنازل', $routed['journal_entry_id']);
}
return;
}
$debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE); $debitAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
$creditAccount = self::getAccountByCode(AccountCodes::WAIVER_FEE_REVENUE); $creditAccount = self::getAccountByCode(AccountCodes::WAIVER_FEE_REVENUE);
if (!$debitAccount || !$creditAccount) return; if (!$debitAccount || !$creditAccount) return;
$description = 'رسوم تنازل عن العضوية — طلب #' . $waiverId;
JournalService::createEntry([ JournalService::createEntry([
'entry_date' => date('Y-m-d'), 'entry_date' => date('Y-m-d'),
'description_ar' => $description, 'description_ar' => $description,
...@@ -1566,12 +1772,40 @@ final class AccountingIntegrationService ...@@ -1566,12 +1772,40 @@ final class AccountingIntegrationService
$amount = (string) ($outstanding['total'] ?? '0.00'); $amount = (string) ($outstanding['total'] ?? '0.00');
if (bccomp($amount, '0.00', 2) <= 0) return; if (bccomp($amount, '0.00', 2) <= 0) return;
$debitAccount = self::getAccountByCode(AccountCodes::MISCELLANEOUS_REVENUE);
$creditAccount = self::getAccountByCode(AccountCodes::ACCOUNTS_RECEIVABLE);
if (!$debitAccount || !$creditAccount) return;
$description = 'إسقاط مديونية عضوية — ' . $reason; $description = 'إسقاط مديونية عضوية — ' . $reason;
$routed = PostingRouter::attempt('member:writeoff', 'writeoff', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'member_id' => $memberId,
'reference_type' => 'member_drop',
'reference_id' => $memberId,
'source_module' => 'members',
'description_ar' => $description,
]);
if ($routed['handled']) {
if ($routed['journal_entry_id'] !== null) {
$db->query(
"UPDATE accounts_receivable
SET status = 'written_off', balance = '0.00', updated_at = ?
WHERE member_id = ? AND status IN ('pending','partial')",
[date('Y-m-d H:i:s'), $memberId]
);
}
return;
}
// Legacy path — corrected. A bad debt is an EXPENSE, not a reduction of
// miscellaneous revenue; 3328 ديون معدومة is the account for it.
$debitAccountId = PostingRouter::accountFor('member:writeoff', '3328', 'writeoff');
$creditAccountId = PostingRouter::accountFor('ar:control', '120301004', 'writeoff');
if ($debitAccountId === null || $creditAccountId === null) {
Logger::error('Member write-off skipped: accounts unresolved', ['member_id' => $memberId]);
return;
}
JournalService::createEntry([ JournalService::createEntry([
'entry_date' => date('Y-m-d'), 'entry_date' => date('Y-m-d'),
'description_ar' => $description, 'description_ar' => $description,
...@@ -1581,8 +1815,8 @@ final class AccountingIntegrationService ...@@ -1581,8 +1815,8 @@ final class AccountingIntegrationService
'source_module' => 'members', 'source_module' => 'members',
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], [ ], [
['account_id' => (int) $debitAccount['id'], 'debit' => $amount, 'credit' => '0.00', 'description_ar' => $description, 'member_id' => $memberId], ['account_id' => $debitAccountId, 'debit' => $amount, 'credit' => '0.00', 'description_ar' => $description, 'member_id' => $memberId],
['account_id' => (int) $creditAccount['id'], 'debit' => '0.00', 'credit' => $amount, 'description_ar' => $description, 'member_id' => $memberId], ['account_id' => $creditAccountId, 'debit' => '0.00', 'credit' => $amount, 'description_ar' => $description, 'member_id' => $memberId],
], true); ], true);
} }
...@@ -1635,11 +1869,23 @@ final class AccountingIntegrationService ...@@ -1635,11 +1869,23 @@ final class AccountingIntegrationService
return; return;
} }
$debitAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); // AccountCodes::SUB_TREASURY_CASH points at 12060102, which is actually the
$creditAccount = self::getAccountByCode(AccountCodes::SUB_TREASURY_CASH); // USD cash box — a sub-treasury settlement posted there would sit in a
// foreign-currency account. Resolve it through a configurable pointer.
$debitAccountId = PostingRouter::accountFor('treasury:main_cash', AccountCodes::CASH_ON_HAND, 'transfer');
$creditAccountId = PostingRouter::accountFor('treasury:sub_cash', null, 'transfer');
if (!$debitAccount || !$creditAccount) { if ($creditAccountId === null) {
Logger::error("Treasury settlement auto-post failed: accounts not found", ['settlement_id' => $settlementId]); $sub = self::getAccountByCode(AccountCodes::SUB_TREASURY_CASH);
if ($sub && ($sub['currency'] ?? 'EGP') === 'EGP') {
$creditAccountId = (int) $sub['id'];
}
}
if ($debitAccountId === null || $creditAccountId === null) {
Logger::error("Treasury settlement auto-post failed: accounts unresolved — map treasury:sub_cash", [
'settlement_id' => $settlementId,
]);
return; return;
} }
...@@ -1649,13 +1895,13 @@ final class AccountingIntegrationService ...@@ -1649,13 +1895,13 @@ final class AccountingIntegrationService
$lines = [ $lines = [
[ [
'account_id' => (int) $debitAccount['id'], 'account_id' => $debitAccountId,
'debit' => $amount, 'debit' => $amount,
'credit' => '0.00', 'credit' => '0.00',
'description_ar' => $description, 'description_ar' => $description,
], ],
[ [
'account_id' => (int) $creditAccount['id'], 'account_id' => $creditAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $amount, 'credit' => $amount,
'description_ar' => $description, 'description_ar' => $description,
...@@ -1673,8 +1919,8 @@ final class AccountingIntegrationService ...@@ -1673,8 +1919,8 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], $lines, true); ], $lines, true);
if ($result['success'] && !empty($result['entry_id'])) { if ($result['success'] && !empty($result['journal_entry_id'])) {
$db->update('treasury_settlements', ['journal_entry_id' => (int) $result['entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$settlementId]); $db->update('treasury_settlements', ['journal_entry_id' => (int) $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$settlementId]);
} elseif (!$result['success']) { } elseif (!$result['success']) {
Logger::error("Treasury settlement journal entry failed", ['settlement_id' => $settlementId, 'error' => $result['error'] ?? 'unknown']); Logger::error("Treasury settlement journal entry failed", ['settlement_id' => $settlementId, 'error' => $result['error'] ?? 'unknown']);
} }
...@@ -1707,10 +1953,10 @@ final class AccountingIntegrationService ...@@ -1707,10 +1953,10 @@ final class AccountingIntegrationService
} }
$debitAccount = self::getAccountByCode($debitAccountCode); $debitAccount = self::getAccountByCode($debitAccountCode);
$creditAccount = self::getAccountByCode(AccountCodes::CASH_ON_HAND); $creditAccountId = PostingRouter::accountFor('treasury:main_cash', AccountCodes::CASH_ON_HAND, 'transfer');
if (!$debitAccount || !$creditAccount) { if (!$debitAccount || $creditAccountId === null) {
Logger::error("Treasury deposit auto-post failed: accounts not found", ['deposit_id' => $depositId]); Logger::error("Treasury deposit auto-post failed: accounts unresolved", ['deposit_id' => $depositId]);
return; return;
} }
...@@ -1729,7 +1975,7 @@ final class AccountingIntegrationService ...@@ -1729,7 +1975,7 @@ final class AccountingIntegrationService
'description_ar' => $description, 'description_ar' => $description,
], ],
[ [
'account_id' => (int) $creditAccount['id'], 'account_id' => $creditAccountId,
'debit' => '0.00', 'debit' => '0.00',
'credit' => $amount, 'credit' => $amount,
'description_ar' => $description, 'description_ar' => $description,
...@@ -1747,8 +1993,8 @@ final class AccountingIntegrationService ...@@ -1747,8 +1993,8 @@ final class AccountingIntegrationService
'is_auto_generated' => 1, 'is_auto_generated' => 1,
], $lines, true); ], $lines, true);
if ($result['success'] && !empty($result['entry_id'])) { if ($result['success'] && !empty($result['journal_entry_id'])) {
$db->update('treasury_deposits', ['journal_entry_id' => (int) $result['entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$depositId]); $db->update('treasury_deposits', ['journal_entry_id' => (int) $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$depositId]);
} elseif (!$result['success']) { } elseif (!$result['success']) {
Logger::error("Treasury deposit journal entry failed", ['deposit_id' => $depositId, 'error' => $result['error'] ?? 'unknown']); Logger::error("Treasury deposit journal entry failed", ['deposit_id' => $depositId, 'error' => $result['error'] ?? 'unknown']);
} }
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
/**
* The single door between module events and the posting engine.
*
* Every auto-posting handler asks the router first. The router returns:
*
* null — no rule is configured for this (stream, stage); the caller should run
* its legacy hardcoded posting so nothing breaks mid-migration.
* array — the engine owned the posting. Even a failure is owned: we do NOT fall
* back after a configured rule fails, because a silent legacy post would
* hide the configuration error that finance needs to see.
*/
final class PostingRouter
{
/** Cached once per request — the engine tables may not exist on a stale env. */
private static ?bool $tablesReady = null;
/**
* @param string $streamCode e.g. 'payment:membership_fee', 'procurement:vendor_invoice'
* @param string $stage accrual | collection | payment | refund | writeoff | transfer
* @param array $ctx see RevenuePostingEngine::post()
*
* @return array|null null = not configured, caller should use its legacy path
*/
public static function route(string $streamCode, string $stage, array $ctx): ?array
{
if (!self::ready()) {
return null;
}
$db = App::getInstance()->db();
$stream = $db->selectOne(
"SELECT id FROM revenue_streams WHERE stream_code = ? AND is_active = 1",
[$streamCode]
);
if (!$stream) {
return null;
}
if (!RevenuePostingEngine::isConfigured((int) $stream['id'], $stage)) {
return null;
}
$result = RevenuePostingEngine::post($streamCode, $ctx + ['stage' => $stage]);
if (!$result['success']) {
Logger::error('Posting rule failed', [
'stream' => $streamCode,
'stage' => $stage,
'ref' => ($ctx['reference_type'] ?? '') . '#' . ($ctx['reference_id'] ?? ''),
'error' => $result['error'] ?? '',
]);
}
return $result;
}
/**
* Convenience for the common shape: try the engine, and tell the caller whether
* it should stop. Returns the journal entry id when the engine posted one.
*
* @return array{handled:bool, journal_entry_id:?int}
*/
public static function attempt(string $streamCode, string $stage, array $ctx): array
{
$result = self::route($streamCode, $stage, $ctx);
if ($result === null) {
return ['handled' => false, 'journal_entry_id' => null];
}
return [
'handled' => true,
'journal_entry_id' => $result['success'] ? (int) $result['journal_entry_id'] : null,
];
}
/**
* Configurable account pointer.
*
* Not every posting is one amount split across accounts. A payroll entry has
* five independent amounts, and a treasury transfer has none — but finance
* still needs to control which account each leg hits without editing PHP.
*
* For those, a stream carries a single-line rule and this returns that line's
* account. Falls back to the supplied chart code when nothing is configured,
* so behaviour is unchanged until someone maps it.
*
* @param string $streamCode e.g. 'payroll:gross_salary'
* @param ?string $fallbackCode chart account code to use when unmapped
*/
public static function accountFor(string $streamCode, ?string $fallbackCode = null, string $stage = 'payment'): ?int
{
$db = App::getInstance()->db();
if ($db === null) {
return null;
}
if (self::ready()) {
$row = $db->selectOne(
"SELECT l.account_id
FROM revenue_streams s
JOIN revenue_posting_rules r
ON r.stream_id = s.id
AND r.stage = ?
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
JOIN revenue_posting_rule_lines l
ON l.rule_id = r.id AND l.is_active = 1
JOIN chart_of_accounts coa
ON coa.id = l.account_id AND coa.is_header = 0 AND coa.is_active = 1
WHERE s.stream_code = ? AND s.is_active = 1
ORDER BY l.sort_order ASC
LIMIT 1",
[$stage, $streamCode]
);
if ($row) {
return (int) $row['account_id'];
}
}
if ($fallbackCode === null) {
return null;
}
// Legacy chart code — but never hand back a header, that posts nowhere.
$acc = $db->selectOne(
"SELECT id, is_header FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_active = 1",
[$fallbackCode]
);
if (!$acc) {
Logger::error('Account pointer unresolved', ['stream' => $streamCode, 'fallback' => $fallbackCode]);
return null;
}
if ((int) $acc['is_header'] === 1) {
Logger::error('Account pointer resolves to a header account — posting would fail', [
'stream' => $streamCode,
'fallback' => $fallbackCode,
]);
return null;
}
return (int) $acc['id'];
}
private static function ready(): bool
{
if (self::$tablesReady !== null) {
return self::$tablesReady;
}
$db = App::getInstance()->db();
if ($db === null) {
return false; // CLI without a bound connection — do not cache this
}
try {
$row = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('revenue_streams','revenue_posting_rules','revenue_posting_rule_lines')"
);
self::$tablesReady = ((int) ($row['n'] ?? 0)) === 3;
} catch (\Throwable $e) {
self::$tablesReady = false;
}
return self::$tablesReady;
}
}
...@@ -9,26 +9,46 @@ use App\Modules\Accounting\AccountCodes; ...@@ -9,26 +9,46 @@ use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\JournalService; use App\Modules\Accounting\Services\JournalService;
/** /**
* Account determination for revenue. * Account determination for the full accounting cycle.
* *
* Resolves the active posting rule for a revenue stream, allocates the collected * Resolves the active posting rule for a (stream, stage) pair, allocates the
* amount across its lines, and produces a balanced journal entry. * document amount across its lines, and produces a balanced journal entry.
* *
* Falls back to the legacy hardcoded AccountCodes mapping when no rule is configured, * A rule carries two dimensions beyond its split:
* so turning this on changes nothing until finance actually configures a stream. *
* stage accrual | collection | payment | refund | writeoff | transfer
* direction inflow → counter account is DEBITED, allocation lines CREDITED
* outflow → allocation lines are DEBITED, counter account CREDITED
*
* So the same allocation maths drives revenue, expense, receivable and payable
* postings. Callers fall back to their legacy hardcoded mapping when no rule is
* configured, so turning this on changes nothing until a stage is actually set up.
*/ */
final class RevenuePostingEngine final class RevenuePostingEngine
{ {
private const SCALE = 2; private const SCALE = 2;
/** Every stage a document can post at. */
public const STAGES = ['accrual', 'collection', 'payment', 'refund', 'writeoff', 'transfer'];
public const STAGE_LABELS = [
'accrual' => 'استحقاق',
'collection' => 'تحصيل',
'payment' => 'صرف',
'refund' => 'ارتجاع',
'writeoff' => 'إعدام / إسقاط',
'transfer' => 'تحويل داخلي',
];
/** /**
* Build and post the journal entry for a collected amount. * Build and post the journal entry for a document amount.
* *
* @param string $streamCode e.g. 'payment:membership_fee' * @param string $streamCode e.g. 'payment:membership_fee'
* @param array $ctx [ * @param array $ctx [
* amount, entry_date, payment_method, treasury_id, branch_id, member_id, * amount, stage, entry_date, payment_method, treasury_id, branch_id,
* reference_type, reference_id, reference_number, source_module, * member_id, supplier_id, employee_id, reference_type, reference_id,
* description_ar, description_en, period_months * reference_number, source_module, description_ar, description_en,
* period_months, service_start_date
* ] * ]
* @return array{success:bool, journal_entry_id?:int, error?:string, used_rule?:bool} * @return array{success:bool, journal_entry_id?:int, error?:string, used_rule?:bool}
*/ */
...@@ -92,9 +112,17 @@ final class RevenuePostingEngine ...@@ -92,9 +112,17 @@ final class RevenuePostingEngine
} }
$entryDate = $ctx['entry_date'] ?? date('Y-m-d'); $entryDate = $ctx['entry_date'] ?? date('Y-m-d');
$rule = self::resolveRule((int) $stream['id'], $entryDate, $ctx); $stage = $ctx['stage'] ?? 'collection';
if (!\in_array($stage, self::STAGES, true)) {
return $blank + ['stream' => $stream, 'error' => 'مرحلة قيد غير معروفة: ' . $stage];
}
$rule = self::resolveRule((int) $stream['id'], $entryDate, $ctx + ['stage' => $stage]);
if (!$rule) { if (!$rule) {
return $blank + ['stream' => $stream, 'error' => 'لا توجد قاعدة توزيع مفعّلة لهذا المصدر']; return $blank + [
'stream' => $stream,
'error' => 'لا توجد قاعدة مفعّلة لمرحلة «' . (self::STAGE_LABELS[$stage] ?? $stage) . '» في هذا المصدر',
];
} }
$lines = $db->select( $lines = $db->select(
...@@ -118,41 +146,58 @@ final class RevenuePostingEngine ...@@ -118,41 +146,58 @@ final class RevenuePostingEngine
$errors = $alloc['errors']; $errors = $alloc['errors'];
$warnings = $alloc['warnings']; $warnings = $alloc['warnings'];
// ── Debit side (where the money landed) ───────────────────────── // ── Direction decides which side each half lands on ─────────────
$debitAccountId = self::resolveDebitAccount($rule, $ctx, $errors); // inflow : counter DEBITED (cash in, or a receivable raised)
// outflow : counter CREDITED (cash out, or a payable raised)
$direction = $rule['direction'] ?? 'inflow';
$isInflow = ($direction !== 'outflow');
// Tax-exclusive profiles gross the entry up: we credit tax on top of the net. $counterAccountId = self::resolveCounterAccount($rule, $ctx, $errors);
// Tax-exclusive profiles gross the entry up: tax sits on top of the net.
$taxExclusive = $taxProfile !== null && (int) ($taxProfile['is_price_inclusive'] ?? 1) === 0; $taxExclusive = $taxProfile !== null && (int) ($taxProfile['is_price_inclusive'] ?? 1) === 0;
$debitTotal = $taxExclusive ? bcadd($alloc['net'], $alloc['tax'], self::SCALE) : $alloc['gross']; $counterTotal = $taxExclusive ? bcadd($alloc['net'], $alloc['tax'], self::SCALE) : $alloc['gross'];
$description = $ctx['description_ar'] ?? ('تحصيل ' . ($stream['name_ar'] ?? $streamCode)); $description = $ctx['description_ar']
?? ((self::STAGE_LABELS[$stage] ?? '') . ' ' . ($stream['name_ar'] ?? $streamCode));
$jLines = []; $jLines = [];
$memberId = isset($ctx['member_id']) && (int) $ctx['member_id'] > 0 ? (int) $ctx['member_id'] : null; $memberId = isset($ctx['member_id']) && (int) $ctx['member_id'] > 0 ? (int) $ctx['member_id'] : null;
$supplierId= isset($ctx['supplier_id']) && (int) $ctx['supplier_id'] > 0 ? (int) $ctx['supplier_id'] : null;
$employeeId= isset($ctx['employee_id']) && (int) $ctx['employee_id'] > 0 ? (int) $ctx['employee_id'] : null;
if ($debitAccountId !== null && bccomp($debitTotal, '0.00', self::SCALE) > 0) { if ($counterAccountId !== null && bccomp($counterTotal, '0.00', self::SCALE) > 0) {
$jLines[] = [ $jLines[] = [
'account_id' => $debitAccountId, 'account_id' => $counterAccountId,
'debit' => $debitTotal, 'debit' => $isInflow ? $counterTotal : '0.00',
'credit' => '0.00', 'credit' => $isInflow ? '0.00' : $counterTotal,
'description_ar' => $description, 'description_ar' => $description,
'member_id' => $memberId, 'member_id' => $memberId,
'supplier_id' => $supplierId,
'employee_id' => $employeeId,
'cost_center_id' => $rule['cost_center_id'] ?? null, 'cost_center_id' => $rule['cost_center_id'] ?? null,
'branch_id' => $ctx['branch_id'] ?? null, 'branch_id' => $ctx['branch_id'] ?? null,
]; ];
} }
// ── Credit side: tax first, then the allocation ───────────────── // ── Tax: output VAT on inflow (credit), input VAT on outflow (debit) ──
if (bccomp($alloc['tax'], '0.00', self::SCALE) > 0) { if (bccomp($alloc['tax'], '0.00', self::SCALE) > 0) {
if ($alloc['tax_account_id'] === null) { $taxAccountId = $alloc['tax_account_id'];
$errors[] = 'لم يتم تحديد حساب ضريبة المخرجات'; if (!$isInflow && $taxProfile !== null && !empty($taxProfile['input_tax_account_id'])) {
$taxAccountId = (int) $taxProfile['input_tax_account_id'];
}
if ($taxAccountId === null) {
$errors[] = $isInflow
? 'لم يتم تحديد حساب ضريبة المخرجات'
: 'لم يتم تحديد حساب ضريبة المدخلات';
} else { } else {
self::assertPostable($alloc['tax_account_id'], 'حساب الضريبة', $errors); self::assertPostable($taxAccountId, 'حساب الضريبة', $errors);
$jLines[] = [ $jLines[] = [
'account_id' => $alloc['tax_account_id'], 'account_id' => $taxAccountId,
'debit' => '0.00', 'debit' => $isInflow ? '0.00' : $alloc['tax'],
'credit' => $alloc['tax'], 'credit' => $isInflow ? $alloc['tax'] : '0.00',
'description_ar' => 'ضريبة قيمة مضافة — ' . $description, 'description_ar' => ($isInflow ? 'ضريبة مخرجات — ' : 'ضريبة مدخلات — ') . $description,
'branch_id' => $ctx['branch_id'] ?? null, 'branch_id' => $ctx['branch_id'] ?? null,
]; ];
} }
...@@ -168,21 +213,25 @@ final class RevenuePostingEngine ...@@ -168,21 +213,25 @@ final class RevenuePostingEngine
$lineDesc = $a['description_ar'] ?: $description; $lineDesc = $a['description_ar'] ?: $description;
// contra_revenue reduces revenue → it is a DEBIT, not a credit. // Contra-revenue always reduces revenue, so it is always a debit —
$isContra = ($a['line_type'] === 'contra_revenue'); // on a collection with a discount and on a refund alike. Every other
// line type follows the entry's direction.
$isContra = ($a['line_type'] === 'contra_revenue');
$lineIsDebit = $isContra ? true : !$isInflow;
$jLines[] = [ $jLines[] = [
'account_id' => $a['account_id'], 'account_id' => $a['account_id'],
'debit' => $isContra ? $a['amount'] : '0.00', 'debit' => $lineIsDebit ? $a['amount'] : '0.00',
'credit' => $isContra ? '0.00' : $a['amount'], 'credit' => $lineIsDebit ? '0.00' : $a['amount'],
'description_ar' => $lineDesc, 'description_ar' => $lineDesc,
'member_id' => \in_array($a['line_type'], ['receivable_offset'], true) ? $memberId : null, 'member_id' => \in_array($a['line_type'], ['receivable_offset', 'writeoff'], true) ? $memberId : null,
'supplier_id' => $a['line_type'] === 'payable_offset' ? $supplierId : null,
'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null, 'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null,
'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null, 'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null,
]; ];
if ($isContra) { if ($isContra && empty($ctx['allow_contra'])) {
$errors[] = 'بند "خصم من الإيراد" يتطلب مصدر خصم صريح — غير مدعوم في تحصيل مباشر'; $errors[] = 'بند «خصم من الإيراد» يتطلب مصدر خصم صريح من المستند';
} }
// Deferred revenue → build the amortisation schedule. // Deferred revenue → build the amortisation schedule.
...@@ -209,8 +258,8 @@ final class RevenuePostingEngine ...@@ -209,8 +258,8 @@ final class RevenuePostingEngine
} }
} }
if ($debitAccountId !== null) { if ($counterAccountId !== null) {
self::assertPostable($debitAccountId, 'الحساب المدين', $errors); self::assertPostable($counterAccountId, $isInflow ? 'الحساب المدين' : 'الحساب الدائن', $errors);
} }
// Final balance check before we hand it to JournalService. // Final balance check before we hand it to JournalService.
...@@ -229,6 +278,8 @@ final class RevenuePostingEngine ...@@ -229,6 +278,8 @@ final class RevenuePostingEngine
return [ return [
'resolved' => true, 'resolved' => true,
'stage' => $stage,
'direction' => $direction,
'stream' => $stream, 'stream' => $stream,
'rule' => $rule, 'rule' => $rule,
'allocation' => $alloc, 'allocation' => $alloc,
...@@ -253,7 +304,7 @@ final class RevenuePostingEngine ...@@ -253,7 +304,7 @@ final class RevenuePostingEngine
} }
/** /**
* Pick the active rule for a stream on a date. * Pick the active rule for a (stream, stage) on a date.
* Most specific scope wins: branch+method > branch > method > global. * Most specific scope wins: branch+method > branch > method > global.
*/ */
public static function resolveRule(int $streamId, string $onDate, array $ctx = []): ?array public static function resolveRule(int $streamId, string $onDate, array $ctx = []): ?array
...@@ -262,17 +313,19 @@ final class RevenuePostingEngine ...@@ -262,17 +313,19 @@ final class RevenuePostingEngine
$branchId = isset($ctx['branch_id']) && $ctx['branch_id'] ? (int) $ctx['branch_id'] : null; $branchId = isset($ctx['branch_id']) && $ctx['branch_id'] ? (int) $ctx['branch_id'] : null;
$method = $ctx['payment_method'] ?? null; $method = $ctx['payment_method'] ?? null;
$stage = $ctx['stage'] ?? 'collection';
$candidates = $db->select( $candidates = $db->select(
"SELECT * FROM revenue_posting_rules "SELECT * FROM revenue_posting_rules
WHERE stream_id = ? WHERE stream_id = ?
AND stage = ?
AND status = 'active' AND status = 'active'
AND effective_from <= ? AND effective_from <= ?
AND (effective_to IS NULL OR effective_to >= ?) AND (effective_to IS NULL OR effective_to >= ?)
AND (branch_id IS NULL OR branch_id = ?) AND (branch_id IS NULL OR branch_id = ?)
AND (payment_method IS NULL OR payment_method = ?) AND (payment_method IS NULL OR payment_method = ?)
ORDER BY effective_from DESC, version DESC", ORDER BY effective_from DESC, version DESC",
[$streamId, $onDate, $onDate, $branchId, $method] [$streamId, $stage, $onDate, $onDate, $branchId, $method]
); );
if (empty($candidates)) { if (empty($candidates)) {
...@@ -297,39 +350,54 @@ final class RevenuePostingEngine ...@@ -297,39 +350,54 @@ final class RevenuePostingEngine
return $candidates[0]; return $candidates[0];
} }
/** Does this stream have an active rule right now? Used by the UI status column. */ /** Does this stream have an active rule for a stage right now? */
public static function isConfigured(int $streamId): bool public static function isConfigured(int $streamId, string $stage = 'collection'): bool
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$row = $db->selectOne( $row = $db->selectOne(
"SELECT COUNT(*) AS n FROM revenue_posting_rules "SELECT COUNT(*) AS n FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ? AND status = 'active'
AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$streamId, $stage]
);
return ((int) ($row['n'] ?? 0)) > 0;
}
/** Which stages this stream has configured — drives the lifecycle strip in the UI. */
public static function configuredStages(int $streamId): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT DISTINCT stage FROM revenue_posting_rules
WHERE stream_id = ? AND status = 'active' WHERE stream_id = ? AND status = 'active'
AND effective_from <= CURDATE() AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE())", AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$streamId] [$streamId]
); );
return ((int) ($row['n'] ?? 0)) > 0; return array_column($rows, 'stage');
} }
// ──────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────
private static function resolveDebitAccount(array $rule, array $ctx, array &$errors): ?int /**
* The account on the opposite side from the allocation lines.
* On an inflow it is debited (cash received, or a receivable raised); on an
* outflow it is credited (cash paid, or a payable raised).
*/
private static function resolveCounterAccount(array $rule, array $ctx, array &$errors): ?int
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$source = $rule['debit_source'] ?? 'auto_treasury'; $source = $rule['debit_source'] ?? 'auto_treasury';
if ($source === 'fixed_account') { if (\in_array($source, ['fixed_account', 'accounts_receivable', 'accounts_payable'], true)) {
if (empty($rule['debit_account_id'])) {
$errors[] = 'قاعدة التوزيع تستخدم حسابًا مدينًا ثابتًا ولكنه غير محدد';
return null;
}
return (int) $rule['debit_account_id'];
}
if ($source === 'accounts_receivable') {
if (empty($rule['debit_account_id'])) { if (empty($rule['debit_account_id'])) {
$errors[] = 'قاعدة التوزيع تستخدم حساب المدينين ولكنه غير محدد'; $errors[] = match ($source) {
'accounts_receivable' => 'القاعدة تستخدم حساب المدينين ولكنه غير محدد',
'accounts_payable' => 'القاعدة تستخدم حساب الدائنين ولكنه غير محدد',
default => 'القاعدة تستخدم حسابًا ثابتًا ولكنه غير محدد',
};
return null; return null;
} }
return (int) $rule['debit_account_id']; return (int) $rule['debit_account_id'];
...@@ -345,7 +413,7 @@ final class RevenuePostingEngine ...@@ -345,7 +413,7 @@ final class RevenuePostingEngine
[$code] [$code]
); );
if (!$account) { if (!$account) {
$errors[] = 'الحساب المدين ' . $code . ' غير موجود في دليل الحسابات'; $errors[] = 'حساب النقدية ' . $code . ' غير موجود في دليل الحسابات';
return null; return null;
} }
return (int) $account['id']; return (int) $account['id'];
...@@ -388,6 +456,7 @@ final class RevenuePostingEngine ...@@ -388,6 +456,7 @@ final class RevenuePostingEngine
'stream_id' => isset($plan['stream']['id']) ? (int) $plan['stream']['id'] : null, 'stream_id' => isset($plan['stream']['id']) ? (int) $plan['stream']['id'] : null,
'rule_id' => isset($plan['rule']['id']) ? (int) $plan['rule']['id'] : null, 'rule_id' => isset($plan['rule']['id']) ? (int) $plan['rule']['id'] : null,
'rule_version' => isset($plan['rule']['version']) ? (int) $plan['rule']['version'] : null, 'rule_version' => isset($plan['rule']['version']) ? (int) $plan['rule']['version'] : null,
'stage' => $plan['stage'] ?? ($ctx['stage'] ?? null),
'journal_entry_id' => $entryId, 'journal_entry_id' => $entryId,
'source_module' => $ctx['source_module'] ?? null, 'source_module' => $ctx['source_module'] ?? null,
'source_reference_type' => $ctx['reference_type'] ?? null, 'source_reference_type' => $ctx['reference_type'] ?? null,
......
...@@ -18,13 +18,15 @@ ...@@ -18,13 +18,15 @@
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"> <div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;color:#991B1B;">حسابات مُعرَّفة في الكود ولا تقبل الترحيل</h3> <h3 style="margin:0;font-size:14px;color:#991B1B;">حسابات مُعرَّفة في الكود ولا تقبل الترحيل</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;"> <div style="font-size:12px;color:#6B7280;margin-top:4px;">
محرك القيود يرفض الترحيل إلى حساب رئيسي. أي قيد يستهدف هذه الحسابات يفشل دون رسالة للمستخدم. محرك القيود يرفض الترحيل إلى حساب رئيسي، والمستدعي يكتفي بتسجيل الخطأ في السجل —
فالقيد يفشل دون أن يظهر شيء للمستخدم. العمود الأخير يوضح ما إذا كان المحرك
يوجّه هذا القيد الآن إلى حساب فرعي صحيح بدلًا منه.
</div> </div>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="data-table" style="width:100%;"> <table class="data-table" style="width:100%;">
<thead> <thead>
<tr><th>الثابت في الكود</th><th>الحساب</th><th>الاسم</th><th>المشكلة</th><th>الأثر</th></tr> <tr><th>الثابت في الكود</th><th>الحساب</th><th>الاسم</th><th>المشكلة</th><th>الأثر</th><th>الحالة الآن</th></tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($legacyBroken as $b): ?> <?php foreach ($legacyBroken as $b): ?>
...@@ -34,6 +36,17 @@ ...@@ -34,6 +36,17 @@
<td><?= e($b['name']) ?></td> <td><?= e($b['name']) ?></td>
<td><span class="badge badge-danger"><?= e($b['issue']) ?></span></td> <td><span class="badge badge-danger"><?= e($b['issue']) ?></span></td>
<td style="font-size:12px;color:#6B7280;"><?= e($b['label']) ?></td> <td style="font-size:12px;color:#6B7280;"><?= e($b['label']) ?></td>
<td>
<?php if (!empty($b['covered'])): ?>
<span class="badge badge-success">مُعالَج عبر المحرك</span>
<div style="font-size:10px;color:#9CA3AF;direction:ltr;text-align:right;margin-top:2px;"><?= e($b['stream']) ?></div>
<?php elseif (!empty($b['stream'])): ?>
<span class="badge badge-warning">يحتاج ربطًا</span>
<div style="font-size:10px;color:#9CA3AF;direction:ltr;text-align:right;margin-top:2px;"><?= e($b['stream']) ?></div>
<?php else: ?>
<span class="badge badge-neutral">غير مستخدم حاليًا</span>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
......
...@@ -14,13 +14,40 @@ ...@@ -14,13 +14,40 @@
— ساري من <?= e($rule['effective_from']) ?> — ساري من <?= e($rule['effective_from']) ?>
</div> </div>
<?php else: ?> <?php else: ?>
<div style="margin-top:6px;"><span class="badge badge-danger">لا توجد قاعدة توزيع — لن يُرحَّل أي قيد</span></div> <div style="margin-top:6px;"><span class="badge badge-danger">لا توجد قاعدة لهذه المرحلة — لن يُرحَّل أي قيد</span></div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- ══════════ Lifecycle stages ══════════ -->
<div class="card" style="margin-bottom:14px;padding:4px;">
<div style="display:flex;gap:4px;flex-wrap:wrap;">
<?php foreach ($stages as $key => $label): ?>
<?php
$isActive = ($key === $stage);
$isSetUp = \in_array($key, $configured, true);
?>
<a href="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>/edit?stage=<?= e($key) ?>"
style="flex:1;min-width:110px;text-align:center;padding:9px 8px;border-radius:6px;text-decoration:none;
font-size:12.5px;font-weight:<?= $isActive ? '600' : '500' ?>;
background:<?= $isActive ? '#1F5FA8' : ($isSetUp ? '#EFF6FF' : 'transparent') ?>;
color:<?= $isActive ? '#fff' : ($isSetUp ? '#1F5FA8' : '#9CA3AF') ?>;
border:1px solid <?= $isActive ? '#1F5FA8' : ($isSetUp ? '#BFDBFE' : '#E5E7EB') ?>;">
<?= e($label) ?>
<div style="font-size:10px;opacity:.85;margin-top:2px;">
<?= $isSetUp ? 'مضبوطة' : 'غير مضبوطة' ?>
</div>
</a>
<?php endforeach; ?>
</div>
</div>
<div style="font-size:11.5px;color:#6B7280;margin:-6px 0 14px;">
كل مرحلة لها قاعدتها المستقلة وإصداراتها. تعديل مرحلة لا يمس المراحل الأخرى.
</div>
<form method="POST" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>" id="rule-form"> <form method="POST" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>" id="rule-form">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="lines" id="lines-payload"> <input type="hidden" name="lines" id="lines-payload">
<input type="hidden" name="stage" value="<?= e($stage) ?>">
<div style="display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:16px;align-items:start;"> <div style="display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:16px;align-items:start;">
...@@ -32,7 +59,20 @@ ...@@ -32,7 +59,20 @@
<div style="padding:16px 18px;"> <div style="padding:16px 18px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div> <div>
<label class="form-label">الحساب المدين (أين ذهب المال)</label> <label class="form-label">اتجاه القيد</label>
<select name="direction" id="direction" class="form-select">
<option value="inflow" <?= (!$rule || ($rule['direction'] ?? 'inflow') !== 'outflow') ? 'selected' : '' ?>>
وارد — الحساب المقابل مدين، البنود دائنة
</option>
<option value="outflow" <?= ($rule && ($rule['direction'] ?? '') === 'outflow') ? 'selected' : '' ?>>
صادر — البنود مدينة، الحساب المقابل دائن
</option>
</select>
<div class="form-help">التحصيل والاستحقاق واردة؛ الصرف والارتجاع والإعدام صادرة.</div>
</div>
<div>
<label class="form-label">الحساب المقابل</label>
<select name="debit_source" id="debit-source" class="form-select"> <select name="debit_source" id="debit-source" class="form-select">
<option value="auto_treasury" <?= (!$rule || $rule['debit_source'] === 'auto_treasury') ? 'selected' : '' ?>> <option value="auto_treasury" <?= (!$rule || $rule['debit_source'] === 'auto_treasury') ? 'selected' : '' ?>>
تلقائي حسب طريقة الدفع والخزنة تلقائي حسب طريقة الدفع والخزنة
...@@ -41,14 +81,17 @@ ...@@ -41,14 +81,17 @@
حساب ثابت حساب ثابت
</option> </option>
<option value="accounts_receivable" <?= ($rule && $rule['debit_source'] === 'accounts_receivable') ? 'selected' : '' ?>> <option value="accounts_receivable" <?= ($rule && $rule['debit_source'] === 'accounts_receivable') ? 'selected' : '' ?>>
حساب مدينين (استحقاق بدون تحصيل) حساب مدينين (استحقاق على عضو)
</option>
<option value="accounts_payable" <?= ($rule && $rule['debit_source'] === 'accounts_payable') ? 'selected' : '' ?>>
حساب دائنين (التزام لمورد)
</option> </option>
</select> </select>
<div class="form-help">التلقائي = الصندوق للنقدي، البنك للشيك والفيزا والتحويل.</div> <div class="form-help">التلقائي = الصندوق للنقدي، البنك للشيك والفيزا والتحويل.</div>
</div> </div>
<div id="debit-account-wrap" style="<?= ($rule && $rule['debit_source'] !== 'auto_treasury') ? '' : 'display:none;' ?>"> <div id="debit-account-wrap" style="<?= ($rule && $rule['debit_source'] !== 'auto_treasury') ? '' : 'display:none;' ?>">
<label class="form-label">اختر الحساب المدين</label> <label class="form-label">اختر الحساب المقابل</label>
<input type="text" class="form-input acct-search" data-target="debit_account_id" <input type="text" class="form-input acct-search" data-target="debit_account_id"
placeholder="ابحث بالكود أو الاسم" placeholder="ابحث بالكود أو الاسم"
value="<?= e($debitLabel ?? '') ?>"> value="<?= e($debitLabel ?? '') ?>">
...@@ -230,6 +273,13 @@ ...@@ -230,6 +273,13 @@
<option value="deferred_revenue">إيراد مؤجل (يُستحق على فترة)</option> <option value="deferred_revenue">إيراد مؤجل (يُستحق على فترة)</option>
<option value="passthrough">تحصيل لحساب الغير (التزام)</option> <option value="passthrough">تحصيل لحساب الغير (التزام)</option>
<option value="receivable_offset">سداد مديونية عضو</option> <option value="receivable_offset">سداد مديونية عضو</option>
<option value="contra_revenue">خصم من الإيراد</option>
<option value="expense">مصروف</option>
<option value="prepaid_expense">مصروف مدفوع مقدمًا</option>
<option value="inventory">مخزون</option>
<option value="asset">أصل</option>
<option value="payable_offset">سداد دائنين</option>
<option value="writeoff">إعدام دين</option>
</select> </select>
</div> </div>
<div> <div>
...@@ -277,7 +327,10 @@ ...@@ -277,7 +327,10 @@
var simAmount = document.getElementById('sim-amount'); var simAmount = document.getElementById('sim-amount');
var simOut = document.getElementById('sim-output'); var simOut = document.getElementById('sim-output');
var taxSelect = document.getElementById('tax-profile'); var taxSelect = document.getElementById('tax-profile');
var dirSelect = document.getElementById('direction');
var csrf = document.querySelector('input[name="_csrf_token"]'); var csrf = document.querySelector('input[name="_csrf_token"]');
var STAGE = <?= json_encode($stage) ?>;
var STAGE_LBL = <?= json_encode($stages[$stage] ?? $stage, JSON_UNESCAPED_UNICODE) ?>;
var existing = <?= json_encode(array_map(static function (array $l): array { var existing = <?= json_encode(array_map(static function (array $l): array {
return [ return [
...@@ -440,6 +493,8 @@ ...@@ -440,6 +493,8 @@
body.append('amount', amount); body.append('amount', amount);
body.append('lines', JSON.stringify(lines)); body.append('lines', JSON.stringify(lines));
body.append('tax_profile_id', taxSelect.value); body.append('tax_profile_id', taxSelect.value);
body.append('direction', dirSelect ? dirSelect.value : 'inflow');
body.append('stage', STAGE);
if (csrf) body.append('_csrf_token', csrf.value); if (csrf) body.append('_csrf_token', csrf.value);
fetch('/accounting/revenue-mapping/simulate', { fetch('/accounting/revenue-mapping/simulate', {
...@@ -463,10 +518,14 @@ ...@@ -463,10 +518,14 @@
} }
var r = data.result; var r = data.result;
var outflow = (r.direction === 'outflow');
var html = ''; var html = '';
html += '<div style="font-size:11.5px;color:#6B7280;margin-bottom:8px;">مرحلة <strong>' + STAGE_LBL + '</strong> — '
+ (outflow ? 'قيد صادر' : 'قيد وارد') + '</div>';
html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;text-align:center;">' html += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px;text-align:center;">'
+ '<div style="background:#F3F4F6;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#6B7280;">المحصَّل</div><div style="font-weight:700;font-size:14px;">' + fmt(r.gross) + '</div></div>' + '<div style="background:#F3F4F6;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#6B7280;">' + (outflow ? 'المصروف' : 'المحصَّل') + '</div><div style="font-weight:700;font-size:14px;">' + fmt(r.gross) + '</div></div>'
+ '<div style="background:#FEF3C7;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#92400E;">الضريبة</div><div style="font-weight:700;font-size:14px;color:#92400E;">' + fmt(r.tax) + '</div></div>' + '<div style="background:#FEF3C7;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#92400E;">الضريبة</div><div style="font-weight:700;font-size:14px;color:#92400E;">' + fmt(r.tax) + '</div></div>'
+ '<div style="background:#ECFDF5;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#065F46;">صافي الإيراد</div><div style="font-weight:700;font-size:14px;color:#065F46;">' + fmt(r.net) + '</div></div>' + '<div style="background:#ECFDF5;border-radius:6px;padding:8px;"><div style="font-size:10px;color:#065F46;">صافي الإيراد</div><div style="font-weight:700;font-size:14px;color:#065F46;">' + fmt(r.net) + '</div></div>'
+ '</div>'; + '</div>';
...@@ -488,38 +547,65 @@ ...@@ -488,38 +547,65 @@
+ '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:70px;">دائن</th>' + '<th style="text-align:left;padding:6px;border-bottom:1px solid #E5E7EB;width:70px;">دائن</th>'
+ '</tr></thead><tbody>'; + '</tr></thead><tbody>';
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;">' // One helper so the two sides can never drift apart.
+ '<span style="color:#6B7280;">النقدية / البنك</span> <span style="font-size:10px;color:#9CA3AF;">(حسب طريقة الدفع)</span>' function row(label, amount, onDebit, bg) {
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + fmt(r.gross) + '</td>' var shade = bg ? 'background:' + bg + ';' : '';
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;"></td></tr>'; return '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;' + shade + '">' + label + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;' + shade + '">'
+ (onDebit ? fmt(amount) : '') + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;' + shade + '">'
+ (onDebit ? '' : fmt(amount)) + '</td></tr>';
}
var counterLabel = outflow
? '<span style="color:#6B7280;">الحساب المقابل</span> <span style="font-size:10px;color:#9CA3AF;">(نقدية / دائنون)</span>'
: '<span style="color:#6B7280;">الحساب المقابل</span> <span style="font-size:10px;color:#9CA3AF;">(نقدية / مدينون)</span>';
html += row(counterLabel, r.gross, !outflow, null);
if (Number(r.tax) > 0) { if (Number(r.tax) > 0) {
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;background:#FFFBEB;">' var taxLabel = (r.tax_account || 'ضريبة القيمة المضافة')
+ (r.tax_account || 'ضريبة القيمة المضافة') + ' <span style="font-size:10px;color:#92400E;">التزام</span>' + ' <span style="font-size:10px;color:#92400E;">' + (outflow ? 'مدخلات' : 'التزام') + '</span>';
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;background:#FFFBEB;"></td>' html += row(taxLabel, r.tax, outflow, '#FFFBEB');
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;background:#FFFBEB;">' + fmt(r.tax) + '</td></tr>';
} }
var typeTags = {
deferred_revenue: ['مؤجل', '#5B21B6'],
passthrough: ['للغير', '#6B7280'],
receivable_offset: ['سداد مديونية', '#6B7280'],
payable_offset: ['سداد دائنين', '#6B7280'],
contra_revenue: ['خصم من الإيراد', '#B45309'],
writeoff: ['إعدام دين', '#6B7280'],
expense: ['مصروف', '#6B7280'],
inventory: ['مخزون', '#6B7280'],
asset: ['أصل', '#6B7280']
};
(r.allocations || []).forEach(function (a) { (r.allocations || []).forEach(function (a) {
var tag = ''; var t = typeTags[a.line_type];
if (a.line_type === 'deferred_revenue') tag = ' <span style="font-size:10px;color:#5B21B6;">مؤجل</span>'; var tag = t ? ' <span style="font-size:10px;color:' + t[1] + ';">' + t[0] + '</span>' : '';
if (a.line_type === 'passthrough') tag = ' <span style="font-size:10px;color:#6B7280;">للغير</span>';
if (a.line_type === 'receivable_offset') tag = ' <span style="font-size:10px;color:#6B7280;">سداد مديونية</span>';
var hdr = a.is_header ? ' <span style="font-size:10px;color:#DC2626;">حساب رئيسي!</span>' : ''; var hdr = a.is_header ? ' <span style="font-size:10px;color:#DC2626;">حساب رئيسي!</span>' : '';
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;">' var label = '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:64px;font-size:10px;">'
+ '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:64px;font-size:10px;">' + (a.account_code || '') + '</span> ' + (a.account_code || '') + '</span> ' + (a.account_name || '') + tag + hdr;
+ (a.account_name || '') + tag + hdr
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;"></td>' // Contra-revenue is always a debit; everything else follows direction.
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + fmt(a.amount) + '</td></tr>'; var onDebit = (a.line_type === 'contra_revenue') ? true : outflow;
html += row(label, a.amount, onDebit, null);
});
var totalDr = 0, totalCr = 0;
function tally(amount, onDebit) { if (onDebit) totalDr += Number(amount || 0); else totalCr += Number(amount || 0); }
tally(r.gross, !outflow);
if (Number(r.tax) > 0) tally(r.tax, outflow);
(r.allocations || []).forEach(function (a) {
tally(a.amount, (a.line_type === 'contra_revenue') ? true : outflow);
}); });
var totalCr = Number(r.tax || 0); var balanced = Math.abs(totalDr - totalCr) < 0.005;
(r.allocations || []).forEach(function (a) { totalCr += Number(a.amount || 0); });
var balanced = Math.abs(totalCr - Number(r.gross)) < 0.005;
html += '<tr style="background:#F9FAFB;font-weight:700;">' html += '<tr style="background:#F9FAFB;font-weight:700;">'
+ '<td style="padding:6px;">الإجمالي</td>' + '<td style="padding:6px;">الإجمالي</td>'
+ '<td style="padding:6px;text-align:left;">' + fmt(r.gross) + '</td>' + '<td style="padding:6px;text-align:left;color:' + (balanced ? '#059669' : '#DC2626') + ';">' + fmt(totalDr) + '</td>'
+ '<td style="padding:6px;text-align:left;color:' + (balanced ? '#059669' : '#DC2626') + ';">' + fmt(totalCr) + '</td></tr>'; + '<td style="padding:6px;text-align:left;color:' + (balanced ? '#059669' : '#DC2626') + ';">' + fmt(totalCr) + '</td></tr>';
html += '</tbody></table>'; html += '</tbody></table>';
...@@ -533,6 +619,7 @@ ...@@ -533,6 +619,7 @@
document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); }); document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); });
simAmount.addEventListener('input', sync); simAmount.addEventListener('input', sync);
taxSelect.addEventListener('change', sync); taxSelect.addEventListener('change', sync);
if (dirSelect) dirSelect.addEventListener('change', sync);
document.getElementById('debit-source').addEventListener('change', function () { document.getElementById('debit-source').addEventListener('change', function () {
document.getElementById('debit-account-wrap').style.display = (this.value === 'auto_treasury') ? 'none' : ''; document.getElementById('debit-account-wrap').style.display = (this.value === 'auto_treasury') ? 'none' : '';
......
<?php $__template->layout('Layout.main'); ?> <?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>توزيع الإيرادات على الحسابات<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>محرك القيود المحاسبية<?php $__template->endSection(); ?>
<?php
$stageLabels = \App\Modules\Accounting\Services\Revenue\RevenuePostingEngine::STAGE_LABELS;
$stageColors = [
'accrual' => '#7C3AED',
'collection' => '#059669',
'payment' => '#B45309',
'refund' => '#DC2626',
'writeoff' => '#6B7280',
'transfer' => '#2563EB',
];
?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:18px;flex-wrap:wrap;"> <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:15px;margin-bottom:18px;flex-wrap:wrap;">
<div> <div>
<h2 style="margin:0 0 4px;">توزيع الإيرادات على الحسابات</h2> <h2 style="margin:0 0 4px;">محرك القيود المحاسبية</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:640px;"> <p style="margin:0;color:#6B7280;font-size:13px;max-width:660px;">
كل مبلغ يُحصَّل في النظام يمر من هنا. حدِّد لكل مصدر إيراد الحساب — أو الحسابات — التي يُرحَّل إليها، كل قيد آلي في النظام يمر من هنا — التحصيل والاستحقاق والصرف والارتجاع والإعدام والتحويل الداخلي.
بنسبة أو بمبلغ ثابت، مع المعالجة الضريبية والإيراد المؤجل. حدِّد لكل مرحلة الحساب — أو الحسابات — التي يُرحَّل إليها، بنسبة أو بمبلغ ثابت،
مع المعالجة الضريبية والإيراد المؤجل.
</p> </p>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
...@@ -29,12 +42,12 @@ ...@@ -29,12 +42,12 @@
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px;">
<?php <?php
$tiles = [ $tiles = [
[صادر الإيراد', (string) $summary['total'], '#111827', ''], [سارات القيد', (string) $summary['total'], '#111827', ''],
['مربوطة بحسابات', (string) $summary['mapped'], '#059669', ''], ['مربوطة بحسابات', (string) $summary['mapped'], '#059669', ''],
['غير مربوطة', (string) $summary['unmapped'], $summary['unmapped'] > 0 ? '#DC2626' : '#059669', 'unmapped'], ['غير مربوطة', (string) $summary['unmapped'], $summary['unmapped'] > 0 ? '#DC2626' : '#059669', 'unmapped'],
['موزَّعة على أكثر من حساب', (string) $summary['split'], '#2563EB', 'split'], ['حسابات لا تقبل الترحيل', (string) ($summary['broken'] ?? 0), ($summary['broken'] ?? 0) > 0 ? '#DC2626' : '#059669', 'broken'],
['على حساب مجمَّع', (string) $summary['catch_all'], $summary['catch_all'] > 0 ? '#D97706' : '#059669', 'catchall'], ['على حساب مجمَّع', (string) $summary['catch_all'], $summary['catch_all'] > 0 ? '#D97706' : '#059669', 'catchall'],
['إيراد مؤجل قائم', money($summary['deferred']), '#7C3AED', ''], ['إيراد مؤجل قائم', money($summary['deferred']), '#7C3AED', ''],
]; ];
foreach ($tiles as [$label, $value, $color, $filter]): foreach ($tiles as [$label, $value, $color, $filter]):
$href = $filter !== '' ? '/accounting/revenue-mapping?status=' . $filter : null; $href = $filter !== '' ? '/accounting/revenue-mapping?status=' . $filter : null;
...@@ -69,6 +82,7 @@ ...@@ -69,6 +82,7 @@
<select name="status" class="form-select"> <select name="status" class="form-select">
<option value="">الكل</option> <option value="">الكل</option>
<option value="unmapped" <?= $status === 'unmapped' ? 'selected' : '' ?>>غير مربوطة</option> <option value="unmapped" <?= $status === 'unmapped' ? 'selected' : '' ?>>غير مربوطة</option>
<option value="broken" <?= $status === 'broken' ? 'selected' : '' ?>>حسابات لا تقبل الترحيل</option>
<option value="catchall" <?= $status === 'catchall' ? 'selected' : '' ?>>على حساب مجمَّع</option> <option value="catchall" <?= $status === 'catchall' ? 'selected' : '' ?>>على حساب مجمَّع</option>
<option value="split" <?= $status === 'split' ? 'selected' : '' ?>>موزَّعة</option> <option value="split" <?= $status === 'split' ? 'selected' : '' ?>>موزَّعة</option>
<option value="review" <?= $status === 'review' ? 'selected' : '' ?>>تحتاج مراجعة</option> <option value="review" <?= $status === 'review' ? 'selected' : '' ?>>تحتاج مراجعة</option>
...@@ -105,9 +119,8 @@ foreach ($streams as $s) { ...@@ -105,9 +119,8 @@ foreach ($streams as $s) {
<table class="data-table" style="width:100%;"> <table class="data-table" style="width:100%;">
<thead> <thead>
<tr> <tr>
<th style="width:24%;">مصدر الإيراد</th> <th style="width:23%;">مسار القيد</th>
<th style="width:34%;">التوزيع الحالي</th> <th style="width:47%;">المراحل والتوزيع</th>
<th style="width:12%;">الضريبة</th>
<th style="width:16%;">الحركة الفعلية</th> <th style="width:16%;">الحركة الفعلية</th>
<th style="width:14%;"></th> <th style="width:14%;"></th>
</tr> </tr>
...@@ -119,7 +132,7 @@ foreach ($streams as $s) { ...@@ -119,7 +132,7 @@ foreach ($streams as $s) {
<div style="font-weight:600;"><?= e($s['name_ar']) ?></div> <div style="font-weight:600;"><?= e($s['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div> <div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div>
<?php if (!empty($s['notes'])): ?> <?php if (!empty($s['notes'])): ?>
<div style="margin-top:4px;font-size:11px;color:#B45309;background:#FEF3C7;padding:3px 6px;border-radius:4px;display:inline-block;"> <div style="margin-top:4px;font-size:11px;color:#B45309;background:#FEF3C7;padding:3px 6px;border-radius:4px;">
<?= e($s['notes']) ?> <?= e($s['notes']) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
...@@ -127,45 +140,63 @@ foreach ($streams as $s) { ...@@ -127,45 +140,63 @@ foreach ($streams as $s) {
<td> <td>
<?php if (!$s['is_mapped']): ?> <?php if (!$s['is_mapped']): ?>
<span class="badge badge-danger">غير مربوط</span> <span class="badge badge-danger">غير مربوط</span>
<div style="font-size:11px;color:#DC2626;margin-top:4px;">لن يُرحَّل أي قيد تلقائي لهذا المصدر</div> <div style="font-size:11px;color:#DC2626;margin-top:4px;">لن يُرحَّل أي قيد تلقائي لهذا المسار</div>
<?php else: ?> <?php else: ?>
<?php foreach ($s['lines'] as $l): ?> <?php foreach ($s['stages'] as $stageKey => $st): ?>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;font-size:12px;"> <?php $col = $stageColors[$stageKey] ?? '#6B7280'; ?>
<span style="min-width:64px;font-weight:600;color:<?= $l['allocation_method'] === 'remainder' ? '#374151' : '#2563EB' ?>;"> <div style="margin-bottom:8px;padding-inline-start:9px;border-inline-start:2px solid <?= $col ?>;">
<?php if ($l['allocation_method'] === 'percentage'): ?> <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;">
<?= rtrim(rtrim(number_format((float) $l['percentage'], 2), '0'), '.') ?>% <span style="font-size:11px;font-weight:600;color:<?= $col ?>;"><?= e($stageLabels[$stageKey] ?? $stageKey) ?></span>
<?php elseif ($l['allocation_method'] === 'fixed'): ?> <?php if (($st['direction'] ?? 'inflow') === 'outflow'): ?>
<?= money($l['fixed_amount']) ?> <span style="font-size:10px;color:#9CA3AF;">صادر</span>
<?php else: ?> <?php endif; ?>
الباقي <?php if (!empty($st['tax_profile_id'])): ?>
<span class="badge badge-warning" style="font-size:10px;">
ضريبة <?= rtrim(rtrim(number_format((float) $st['tax_rate'], 2), '0'), '.') ?>%
</span>
<?php endif; ?> <?php endif; ?>
</span> <?php if (count($st['lines']) > 1): ?>
<span style="color:#9CA3AF;"></span> <span class="badge badge-info" style="font-size:10px;">موزَّع على <?= count($st['lines']) ?></span>
<span style="direction:ltr;color:#6B7280;font-size:11px;"><?= e($l['account_code']) ?></span> <?php endif; ?>
<span><?= e($l['account_name']) ?></span> </div>
<?php if ($l['line_type'] === 'deferred_revenue'): ?> <?php foreach ($st['lines'] as $l): ?>
<span class="badge badge-info" style="font-size:10px;">مؤجل</span> <div style="display:flex;align-items:center;gap:6px;font-size:12px;line-height:1.9;flex-wrap:wrap;">
<?php elseif ($l['line_type'] === 'passthrough'): ?> <span style="min-width:56px;font-weight:600;color:<?= $l['allocation_method'] === 'remainder' ? '#374151' : '#2563EB' ?>;">
<span class="badge badge-neutral" style="font-size:10px;">تحصيل للغير</span> <?php if ($l['allocation_method'] === 'percentage'): ?>
<?php elseif ($l['line_type'] === 'receivable_offset'): ?> <?= rtrim(rtrim(number_format((float) $l['percentage'], 2), '0'), '.') ?>%
<span class="badge badge-neutral" style="font-size:10px;">سداد مديونية</span> <?php elseif ($l['allocation_method'] === 'fixed'): ?>
<?php endif; ?> <?= number_format((float) $l['fixed_amount'], 2) ?>
<?php if ((int) $l['is_header'] === 1): ?> <?php else: ?>
<span class="badge badge-danger" style="font-size:10px;">حساب رئيسي — الترحيل يفشل</span> الباقي
<?php endif; ?> <?php endif; ?>
</span>
<span style="color:#9CA3AF;"></span>
<span style="direction:ltr;color:#6B7280;font-size:11px;"><?= e($l['account_code']) ?></span>
<span><?= e($l['account_name']) ?></span>
<?php
$typeBadge = match ($l['line_type']) {
'deferred_revenue' => ['مؤجل', 'badge-info'],
'passthrough' => ['تحصيل للغير', 'badge-neutral'],
'receivable_offset' => ['سداد مديونية', 'badge-neutral'],
'payable_offset' => ['سداد دائنين', 'badge-neutral'],
'contra_revenue' => ['خصم من الإيراد', 'badge-warning'],
'writeoff' => ['إعدام دين', 'badge-neutral'],
'expense' => ['مصروف', 'badge-neutral'],
'inventory' => ['مخزون', 'badge-neutral'],
'asset' => ['أصل', 'badge-neutral'],
default => null,
};
?>
<?php if ($typeBadge): ?>
<span class="badge <?= $typeBadge[1] ?>" style="font-size:10px;"><?= e($typeBadge[0]) ?></span>
<?php endif; ?>
<?php if ((int) $l['is_header'] === 1): ?>
<span class="badge badge-danger" style="font-size:10px;">حساب رئيسي — الترحيل يفشل</span>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
<?php if ($s['is_catchall']): ?>
<div style="font-size:11px;color:#B45309;margin-top:3px;">يُرحَّل إلى حساب مجمَّع</div>
<?php endif; ?>
<?php endif; ?>
</td>
<td>
<?php if (!empty($s['tax_profile_id'])): ?>
<span class="badge badge-warning"><?= rtrim(rtrim(number_format((float) $s['tax_rate'], 2), '0'), '.') ?>%</span>
<div style="font-size:11px;color:#6B7280;margin-top:3px;"><?= e($s['tax_name']) ?></div>
<?php else: ?>
<span style="color:#9CA3AF;font-size:12px;">بدون</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
...@@ -177,13 +208,18 @@ foreach ($streams as $s) { ...@@ -177,13 +208,18 @@ foreach ($streams as $s) {
<?php endif; ?> <?php endif; ?>
</td> </td>
<td style="text-align:left;"> <td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?> <div style="display:flex;flex-direction:column;gap:4px;align-items:stretch;">
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-primary"> <?php if (!empty($s['stages'])): ?>
<?= $s['is_mapped'] ? 'تعديل التوزيع' : 'ربط الحسابات' ?> <?php foreach (array_keys($s['stages']) as $stageKey): ?>
</a> <a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit?stage=<?= e($stageKey) ?>"
<?php else: ?> class="btn btn-sm btn-outline" style="font-size:11px;padding:3px 8px;">
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a> <?= e($stageLabels[$stageKey] ?? $stageKey) ?>
<?php endif; ?> </a>
<?php endforeach; ?>
<?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-primary">ربط الحسابات</a>
<?php endif; ?>
</div>
</td> </td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
......
<?php
declare(strict_types=1);
/**
* Generalise the posting engine from "revenue collection" to the full accounting
* cycle.
*
* Two new dimensions on a rule:
*
* stage — where in a document's life the posting happens.
* accrual the obligation arises (invoice raised, fine imposed)
* collection money moves in
* payment money moves out
* refund money returned to the counterparty
* writeoff the balance is abandoned
* transfer money moves between our own accounts
*
* direction — inflow : counter account is DEBITED, allocation lines CREDITED
* outflow : allocation lines are DEBITED, counter account CREDITED
*
* One stream therefore carries a rule per stage, and the same allocation maths
* drives revenue, expense, receivable and payable postings.
*/
return function (\App\Core\Database $db): void {
// ── Rules: stage + direction ─────────────────────────────────────────
$cols = $db->select(
"SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'revenue_posting_rules'"
);
$have = array_column($cols, 'column_name');
$have = array_map('strtolower', $have);
if (!\in_array('stage', $have, true)) {
$db->raw("
ALTER TABLE `revenue_posting_rules`
ADD COLUMN `stage` ENUM('accrual','collection','payment','refund','writeoff','transfer')
NOT NULL DEFAULT 'collection' AFTER `version`
");
}
if (!\in_array('direction', $have, true)) {
$db->raw("
ALTER TABLE `revenue_posting_rules`
ADD COLUMN `direction` ENUM('inflow','outflow') NOT NULL DEFAULT 'inflow' AFTER `stage`
");
}
// The counter account can now also be a payables account.
$db->raw("
ALTER TABLE `revenue_posting_rules`
MODIFY COLUMN `debit_source`
ENUM('auto_treasury','fixed_account','accounts_receivable','accounts_payable')
NOT NULL DEFAULT 'auto_treasury'
COMMENT 'how the counter account is resolved; which side it lands on is set by direction'
");
// ── Lines: expense-side and settlement line types ────────────────────
$db->raw("
ALTER TABLE `revenue_posting_rule_lines`
MODIFY COLUMN `line_type`
ENUM(
'revenue',
'deferred_revenue',
'passthrough',
'contra_revenue',
'receivable_offset',
'expense',
'prepaid_expense',
'asset',
'inventory',
'payable_offset',
'writeoff',
'equity'
) NOT NULL DEFAULT 'revenue'
");
// ── Streams: mark which side of the ledger they belong to ────────────
$db->raw("
ALTER TABLE `revenue_streams`
MODIFY COLUMN `category`
ENUM(
'membership','subscription','activity','facility','transfer',
'penalty','retail','rental','academy','other',
'procurement','payroll','treasury','writeoff'
) NOT NULL DEFAULT 'other'
");
// ── Index the new lookup shape ───────────────────────────────────────
$idx = $db->select(
"SELECT index_name FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'revenue_posting_rules'
AND index_name = 'idx_posting_rule_stage'"
);
if (empty($idx)) {
$db->raw("
ALTER TABLE `revenue_posting_rules`
ADD INDEX `idx_posting_rule_stage` (`stream_id`, `stage`, `status`, `effective_from`)
");
}
// ── Posting log: record which stage produced the entry ───────────────
$logCols = $db->select(
"SELECT column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'revenue_posting_log'"
);
$haveLog = array_map('strtolower', array_column($logCols, 'column_name'));
if (!\in_array('stage', $haveLog, true)) {
$db->raw("
ALTER TABLE `revenue_posting_log`
ADD COLUMN `stage` VARCHAR(20) NULL AFTER `rule_version`
");
}
};
<?php
declare(strict_types=1);
/**
* Create the postable accounts the full cycle needs but the chart never had.
*
* Every one of these fixes a posting path that currently fails silently, because
* JournalService refuses header accounts and the auto-post callers only log:
*
* 230601 الموردون header → the entire procurement cycle cannot post
* 310103 حصة الشركة missing → payroll drops the employer insurance line, and the
* balancing fallback then inflates the bank credit to
* force the entry to balance
*
* Only new leaf accounts are created. No existing account is reclassified and no
* balance moves — reclassifying a leaf that already carries a balance would strand
* that balance and break the postings that rely on it.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$ensure = function (
string $code,
string $nameAr,
string $nameEn,
string $type,
string $nature,
string $parentCode
) use ($db, $now): void {
if ($db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$code])) {
return;
}
$parent = $db->selectOne(
"SELECT id, level, is_header FROM chart_of_accounts WHERE account_code = ?",
[$parentCode]
);
// Only hang children off an account that is already a header. Turning a
// posting account into a header mid-life orphans its balance.
if (!$parent || (int) $parent['is_header'] !== 1) {
return;
}
$db->insert('chart_of_accounts', [
'account_code' => $code,
'name_ar' => $nameAr,
'name_en' => $nameEn,
'account_type' => $type,
'account_nature' => $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,
]);
};
// ── Payables — unblocks the procurement cycle ────────────────────────
$ensure('230601002', 'الموردون — محليون', 'Trade Payables — Local', 'liability', 'credit', '230601');
$ensure('230601003', 'الموردون — خارجيون', 'Trade Payables — Foreign', 'liability', 'credit', '230601');
// ── Payroll — 310103 is referenced by AccountCodes but never existed ─
$ensure('310103', 'حصة الشركة في التأمينات الاجتماعية', 'Employer Social Insurance Share', 'expense', 'debit', '3101');
$ensure('310104', 'مكافآت وحوافز', 'Bonuses and Incentives', 'expense', 'debit', '3101');
};
<?php
declare(strict_types=1);
/**
* Register every posting path in the ERP as a configurable stream.
*
* Two kinds of entry are created:
*
* split rules — one amount divided across accounts (the allocator runs)
* account pointers — a single-line rule the code reads as "which account is this
* leg", for postings whose amounts are computed elsewhere
* (payroll components, treasury legs, COGS, rental legs)
*
* Both are edited from the same screen. Every rule here reproduces the behaviour
* the code already had, EXCEPT where the legacy code pointed at a header account
* and therefore could not post at all — those are pointed at the correct postable
* leaf, because "keep current behaviour" for a posting that silently fails means
* keeping a bug.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
$accId = function (string $code) use ($db): ?int {
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0 AND is_header = 0",
[$code]
);
return $row ? (int) $row['id'] : null;
};
/**
* @param string $code stream code
* @param array $def [name_ar, module, category, direction]
* @param array $stages stage => [counter_source, counter_code|null, lines[]]
* each line: [account_code, line_type, method, value, desc]
*/
$ensureStream = function (string $code, array $def, array $stages) use ($db, $accId, $now): void {
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if (!$stream) {
$streamId = $db->insert('revenue_streams', [
'stream_code' => $code,
'name_ar' => $def['name_ar'],
'name_en' => $def['name_en'] ?? null,
'source_module' => $def['module'],
'source_event' => $def['event'] ?? null,
'source_key' => $def['key'] ?? null,
'category' => $def['category'],
'default_direction' => ($def['direction'] ?? 'inflow') === 'outflow' ? 'outflow' : 'inflow',
'is_system' => 1,
'is_active' => 1,
'notes' => $def['notes'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$streamId = (int) $stream['id'];
}
foreach ($stages as $stage => $spec) {
$existing = $db->selectOne(
"SELECT id FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?",
[$streamId, $stage]
);
if ($existing) {
continue;
}
// Every line must resolve to a real postable account or the rule is
// pointless — skip rather than create something that fails at post time.
$resolved = [];
foreach ($spec['lines'] as $line) {
$id = $accId($line[0]);
if ($id === null) {
continue 2;
}
$resolved[] = [
'account_id' => $id,
'line_type' => $line[1] ?? 'revenue',
'method' => $line[2] ?? 'remainder',
'value' => $line[3] ?? null,
'desc' => $line[4] ?? null,
];
}
if (empty($resolved)) {
continue;
}
$counterId = null;
if (!empty($spec['counter_code'])) {
$counterId = $accId($spec['counter_code']);
if ($counterId === null) {
continue;
}
}
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => 1,
'stage' => $stage,
'direction' => $spec['direction'] ?? ($def['direction'] ?? 'inflow'),
'name_ar' => $spec['name_ar'] ?? 'القاعدة الافتراضية',
'debit_source' => $spec['counter_source'] ?? 'auto_treasury',
'debit_account_id' => $counterId,
'status' => 'active',
'effective_from' => '2000-01-01',
'notes' => $spec['notes'] ?? null,
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
foreach ($resolved as $i => $line) {
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => $i + 1,
'line_type' => $line['line_type'],
'allocation_method' => $line['method'],
'fixed_amount' => $line['method'] === 'fixed' ? $line['value'] : null,
'percentage' => $line['method'] === 'percentage' ? $line['value'] : null,
'percentage_base' => 'net_after_fixed',
'account_id' => $line['account_id'],
'recognition_method' => 'immediate',
'description_ar' => $line['desc'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
};
// ══════════════════════════════════════════════════════════════════
// RECEIVABLES CONTROL — the account every accrual and write-off uses
// ══════════════════════════════════════════════════════════════════
$ensureStream('ar:control', [
'name_ar' => 'حساب المدينين — أعضاء', 'name_en' => 'Member Receivables Control',
'module' => 'accounting', 'category' => 'other',
'notes' => 'الحساب الذي تُسجَّل عليه مديونيات الأعضاء — كان يشير إلى حساب رئيسي لا يقبل الترحيل',
], [
'accrual' => ['lines' => [['120301004', 'revenue', 'remainder', null, 'مدينون — أعضاء']]],
'writeoff' => ['lines' => [['120301004', 'revenue', 'remainder', null, 'مدينون — أعضاء']]],
]);
// ══════════════════════════════════════════════════════════════════
// ACCRUALS — the obligation arises. Dr Receivable / Cr Revenue
// ══════════════════════════════════════════════════════════════════
$accruals = [
'fine:imposed' => [
'name_ar' => 'استحقاق غرامة', 'module' => 'fines', 'category' => 'penalty',
'revenue' => '410512', 'desc' => 'إيرادات غرامات',
],
'installment:plan' => [
'name_ar' => 'استحقاق خطة أقساط', 'module' => 'installments', 'category' => 'membership',
'revenue' => '410510', 'desc' => 'إيرادات عضوية — أقساط',
],
'transfer:separation_fee' => [
'name_ar' => 'استحقاق رسوم فصل', 'module' => 'transfers', 'category' => 'transfer',
'revenue' => '410515', 'desc' => 'رسوم فصل',
'notes' => 'يُرحَّل إلى «إيرادات متنوعه» — يحتاج حسابًا مخصصًا',
],
'transfer:divorce_fee' => [
'name_ar' => 'استحقاق رسوم طلاق', 'module' => 'transfers', 'category' => 'transfer',
'revenue' => '410302', 'desc' => 'رسوم طلاق',
'notes' => 'مربوط بحساب «محل 1» (إيجار محل) — ربط خاطئ يحتاج تصحيحًا',
],
'transfer:death_fee' => [
'name_ar' => 'استحقاق رسوم نقل وفاة', 'module' => 'transfers', 'category' => 'transfer',
'revenue' => '410515', 'desc' => 'رسوم نقل وفاة',
'notes' => 'يُرحَّل إلى «إيرادات متنوعه» — يحتاج حسابًا مخصصًا',
],
'waiver:fee' => [
'name_ar' => 'استحقاق رسوم تنازل', 'module' => 'waivers', 'category' => 'transfer',
'revenue' => '410515', 'desc' => 'رسوم تنازل',
'notes' => 'أكبر مبلغ في الحساب المجمَّع — يحتاج حسابًا مخصصًا',
],
];
foreach ($accruals as $code => $a) {
$ensureStream($code, [
'name_ar' => $a['name_ar'],
'module' => $a['module'],
'category' => $a['category'],
'notes' => $a['notes'] ?? null,
], [
'accrual' => [
'counter_source' => 'accounts_receivable',
'counter_code' => '120301004',
'name_ar' => 'استحقاق — مدينون مقابل إيراد',
'lines' => [[$a['revenue'], 'revenue', 'remainder', null, $a['desc']]],
],
]);
}
// ══════════════════════════════════════════════════════════════════
// WRITE-OFF — Dr Bad Debt Expense / Cr Receivable
// ══════════════════════════════════════════════════════════════════
$ensureStream('member:writeoff', [
'name_ar' => 'إسقاط مديونية عضو', 'name_en' => 'Member Bad Debt Write-off',
'module' => 'members', 'category' => 'writeoff', 'direction' => 'outflow',
'notes' => 'كان يُقيَّد خصمًا من «إيرادات متنوعه» — الصحيح مصروف ديون معدومة',
], [
'writeoff' => [
'direction' => 'outflow',
'counter_source' => 'accounts_receivable',
'counter_code' => '120301004',
'name_ar' => 'إعدام دين — مصروف مقابل مدينون',
'lines' => [['3328', 'writeoff', 'remainder', null, 'ديون معدومة']],
],
]);
// ══════════════════════════════════════════════════════════════════
// FACILITY / GUEST / TOURNAMENT COLLECTIONS
// ══════════════════════════════════════════════════════════════════
$collections = [
'facility:entry' => ['name_ar' => 'تذاكر دخول المرافق', 'module' => 'facilities', 'category' => 'facility', 'revenue' => '410518', 'desc' => 'إيرادات مرافق'],
'carnet:guest_entry' => ['name_ar' => 'دخول ضيوف بالكارنيه', 'module' => 'carnets', 'category' => 'facility', 'revenue' => '410518', 'desc' => 'إيرادات دعوات'],
'tournament:fee' => ['name_ar' => 'رسوم بطولة', 'module' => 'tournaments', 'category' => 'activity', 'revenue' => '410517', 'desc' => 'إيرادات بطولات'],
];
foreach ($collections as $code => $c) {
$ensureStream($code, [
'name_ar' => $c['name_ar'], 'module' => $c['module'], 'category' => $c['category'],
], [
'collection' => [
'counter_source' => 'auto_treasury',
'name_ar' => 'تحصيل نقدي',
'lines' => [[$c['revenue'], 'revenue', 'remainder', null, $c['desc']]],
],
]);
}
// ══════════════════════════════════════════════════════════════════
// SALES — refund split + COGS/inventory pointers
// ══════════════════════════════════════════════════════════════════
$ensureStream('sales:refund', [
'name_ar' => 'مرتجع مبيعات', 'module' => 'sales', 'category' => 'retail', 'direction' => 'outflow',
], [
'refund' => [
'direction' => 'outflow',
'counter_source' => 'auto_treasury',
'name_ar' => 'رد نقدي مقابل تخفيض إيراد',
'lines' => [['410515', 'contra_revenue', 'remainder', null, 'مرتجع مبيعات — تخفيض إيراد']],
],
]);
$pointers = [
'sales:cogs' => ['name_ar' => 'تكلفة البضاعة المباعة', 'module' => 'sales', 'category' => 'retail', 'stage' => 'accrual', 'code' => '3172', 'type' => 'expense', 'desc' => 'تكلفة بضاعة مباعة'],
'sales:inventory' => ['name_ar' => 'المخزون — مبيعات', 'module' => 'sales', 'category' => 'retail', 'stage' => 'accrual', 'code' => '120209', 'type' => 'inventory', 'desc' => 'خصم من المخزون'],
'procurement:inventory_receipt' => ['name_ar' => 'استلام مخزون من مورد', 'module' => 'procurement', 'category' => 'procurement', 'stage' => 'accrual', 'code' => '120209', 'type' => 'inventory', 'desc' => 'مخزون — فاتورة مورد'],
'procurement:input_tax' => ['name_ar' => 'ضريبة مدخلات المشتريات', 'module' => 'procurement', 'category' => 'procurement', 'stage' => 'accrual', 'code' => '12041106', 'type' => 'asset', 'desc' => 'ضريبة مدخلات', 'notes' => 'كان يشير إلى حساب ضريبة المخرجات الرئيسي — ربط خاطئ'],
'procurement:payable' => ['name_ar' => 'حساب الموردين', 'module' => 'procurement', 'category' => 'procurement', 'stage' => 'accrual', 'code' => '230601002','type' => 'payable_offset', 'desc' => 'دائنون — موردون', 'notes' => 'كان يشير إلى «الموردون» وهو حساب رئيسي لا يقبل الترحيل'],
'procurement:cash_out' => ['name_ar' => 'صرف للموردين', 'module' => 'procurement', 'category' => 'procurement', 'stage' => 'payment', 'code' => '12060101', 'type' => 'asset', 'desc' => 'صرف نقدي/بنكي'],
'payroll:gross_salary' => ['name_ar' => 'مصروف الأجور', 'module' => 'hr', 'category' => 'payroll', 'stage' => 'payment', 'code' => '310101', 'type' => 'expense', 'desc' => 'مصروفات رواتب'],
'payroll:employer_insurance' => ['name_ar' => 'حصة صاحب العمل في التأمينات', 'module' => 'hr', 'category' => 'payroll', 'stage' => 'payment', 'code' => '310103', 'type' => 'expense', 'desc' => 'حصة صاحب العمل', 'notes' => 'الحساب لم يكن موجودًا أصلًا — القيد كان يُوازَن بتضخيم بند البنك'],
'payroll:net_paid' => ['name_ar' => 'صافي الرواتب المصروفة', 'module' => 'hr', 'category' => 'payroll', 'stage' => 'payment', 'code' => '12060201', 'type' => 'asset', 'desc' => 'صرف رواتب'],
'payroll:insurance_payable' => ['name_ar' => 'تأمينات مستحقة', 'module' => 'hr', 'category' => 'payroll', 'stage' => 'payment', 'code' => '23080601', 'type' => 'expense', 'desc' => 'تأمينات مستحقة'],
'payroll:tax_withheld' => ['name_ar' => 'ضريبة كسب العمل المستقطعة', 'module' => 'hr', 'category' => 'payroll', 'stage' => 'payment', 'code' => '23080403', 'type' => 'expense', 'desc' => 'ضرائب مستحقة', 'notes' => 'كان يشير إلى «جاري مصلحة الضرائب» وهو حساب رئيسي'],
'treasury:main_cash' => ['name_ar' => 'الخزنة الرئيسية', 'module' => 'treasury', 'category' => 'treasury', 'stage' => 'transfer', 'code' => '12060101', 'type' => 'asset', 'desc' => 'الخزنة الرئيسية'],
'rental:cash_in' => ['name_ar' => 'تحصيل إيجار', 'module' => 'rentals', 'category' => 'rental', 'stage' => 'collection', 'code' => '12060101', 'type' => 'asset', 'desc' => 'تحصيل فاتورة إيجار'],
'rental:base' => ['name_ar' => 'إيراد الإيجار الأساسي', 'module' => 'rentals', 'category' => 'rental', 'stage' => 'collection', 'code' => '410521', 'type' => 'revenue', 'desc' => 'إيجار'],
'rental:utilities' => ['name_ar' => 'مرافق الإيجار', 'module' => 'rentals', 'category' => 'rental', 'stage' => 'collection', 'code' => '410515', 'type' => 'revenue', 'desc' => 'مرافق إيجار'],
'rental:output_tax' => ['name_ar' => 'ضريبة القيمة المضافة — إيجار', 'module' => 'rentals', 'category' => 'rental', 'stage' => 'collection', 'code' => '23080404', 'type' => 'passthrough', 'desc' => 'ضريبة قيمة مضافة', 'notes' => 'كان يشير إلى حساب رئيسي — لم يكن يُرحَّل'],
'rental:late_fee' => ['name_ar' => 'غرامة تأخير إيجار', 'module' => 'rentals', 'category' => 'rental', 'stage' => 'collection', 'code' => '410512', 'type' => 'revenue', 'desc' => 'غرامة تأخير'],
];
foreach ($pointers as $code => $p) {
$ensureStream($code, [
'name_ar' => $p['name_ar'],
'module' => $p['module'],
'category' => $p['category'],
'notes' => $p['notes'] ?? null,
], [
$p['stage'] => [
'counter_source' => 'fixed_account',
'counter_code' => $p['code'],
'name_ar' => 'مؤشر حساب',
'notes' => 'مؤشر حساب — يحدد الحساب فقط، لا يوزّع مبلغًا',
'lines' => [[$p['code'], $p['type'], 'remainder', null, $p['desc']]],
],
]);
}
// The sub-treasury cash account is deliberately left unmapped: AccountCodes
// points it at 12060102 «الصندوق بالدولار», which is the USD box, and there is
// no EGP sub-treasury account in the chart to point it at. It surfaces on the
// diagnostics page as unmapped so finance chooses the right account.
$ensureStream('treasury:sub_cash', [
'name_ar' => 'الخزنة الفرعية — الأنشطة الرياضية',
'name_en' => 'Sub-Treasury Cash',
'module' => 'treasury', 'category' => 'treasury',
'notes' => 'غير مربوط — الكود يشير إلى «الصندوق بالدولار» وهو حساب بالعملة الأجنبية. اختر حساب الخزنة الفرعية بالجنيه.',
], []);
};
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