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
<?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;
}
}
...@@ -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>
......
<?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');
};
This diff is collapsed.
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