Commit bb3e8ccd authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(accounting): configurable revenue posting engine (account determination)

Replaces the hardcoded AccountCodes::creditAccountForPaymentType() match
statement with a versioned, effective-dated mapping that finance controls
from /accounting/revenue-mapping.

Every collected amount can now be split across multiple GL accounts by flat
amount, percentage, or remainder, with VAT handled as its own layer and
deferred revenue amortised over the service period.

What the live DB showed, and this addresses:
- 4,256,399.96 EGP across 129 transactions posted to a single catch-all
  account (410515 إيرادات متنوعه) — waiver, separation, death, foreign
  membership, early settlement and four payment types that had no rule in
  the code at all and silently fell through to `default`.
- 240,582 EGP of divorce fees posted to 410302 «محل 1», a shop rental account.
- 120301 العملاء and 230804 جاري مصلحة الضرائب are header accounts, and
  JournalService rejects posting to headers — so every AR and VAT entry has
  been failing silently. accounts_receivable holds 0 rows against 970,592.67
  EGP of unpaid instalments.

Model follows SAP account determination / Dynamics posting profiles, adapted
to Egyptian VAT law 67/2016 and EAS 48 revenue recognition:

- revenue_streams              catalogue of every chargeable thing
- revenue_tax_profiles         rate + inclusive/exclusive + treatment
- revenue_posting_rules        versioned, effective-dated, scopeable
- revenue_posting_rule_lines   the split components
- revenue_posting_log          which rule version produced which entry
- revenue_recognition_schedules deferred revenue amortisation

Allocation order is fixed and deterministic: tax extraction, then fixed
amounts, then percentages, then a mandatory remainder line that absorbs
rounding residue so the entry always balances.

Tax is a separate layer rather than a split because inclusive and exclusive
pricing are not the same number: 14% of a tax-inclusive 1140 is 140 on
revenue of 1000, not 159.60. Deferral is separate for the same reason — it
is a split across periods, not accounts.

Adds two postable accounts the chart was missing: 120301004 أعضاء النادي
(مدينون) and 12041106 ضريبة القيمة المضافة — مدخلات.

Seeded rules reproduce current posting behaviour exactly, so this deploy
moves no reported number. Streams landing in a catch-all are flagged for
review rather than silently re-pointed — repointing them moves real revenue
between accounts and is finance's decision.

Unconfigured streams fall through to the legacy path unchanged.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 14972ee2
......@@ -144,6 +144,20 @@ return [
['GET', '/accounting/documentary-credits/{id:\d+}', 'Accounting\Controllers\DocumentaryCreditController@show', ['auth'], 'accounting.lc.view'],
['POST', '/accounting/documentary-credits/{id:\d+}/status', 'Accounting\Controllers\DocumentaryCreditController@updateStatus', ['auth', 'csrf'], 'accounting.lc.manage'],
// ── Revenue Mapping (account determination) ─────────────
['GET', '/accounting/revenue-mapping', 'Accounting\Controllers\RevenueMappingController@index', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/diagnostics', 'Accounting\Controllers\RevenueMappingController@diagnostics', ['auth'], 'accounting.revenue_mapping.view'],
['GET', '/accounting/revenue-mapping/tax-profiles', 'Accounting\Controllers\RevenueMappingController@taxProfiles', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/tax-profiles', 'Accounting\Controllers\RevenueMappingController@storeTaxProfile', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['POST', '/accounting/revenue-mapping/tax-profiles/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@updateTaxProfile', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/recognition', 'Accounting\Controllers\RevenueMappingController@recognition', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/recognition/run', 'Accounting\Controllers\RevenueMappingController@runRecognition', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/search-accounts', 'Accounting\Controllers\RevenueMappingController@searchAccounts', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/simulate', 'Accounting\Controllers\RevenueMappingController@simulate', ['auth', 'csrf'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/sync', 'Accounting\Controllers\RevenueMappingController@sync', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
['GET', '/accounting/revenue-mapping/{id:\d+}/edit', 'Accounting\Controllers\RevenueMappingController@edit', ['auth'], 'accounting.revenue_mapping.view'],
['POST', '/accounting/revenue-mapping/{id:\d+}', 'Accounting\Controllers\RevenueMappingController@update', ['auth', 'csrf'], 'accounting.revenue_mapping.manage'],
// ── Letters of Guarantee ────────────────────────────────
['GET', '/accounting/guarantees', 'Accounting\Controllers\LetterOfGuaranteeController@index', ['auth'], 'accounting.guarantee.view'],
['GET', '/accounting/guarantees/create', 'Accounting\Controllers\LetterOfGuaranteeController@create', ['auth'], 'accounting.guarantee.manage'],
......
......@@ -40,6 +40,13 @@ final class AccountingIntegrationService
return;
}
// ── Account determination ───────────────────────────────
// A configured posting rule wins. Without one we fall through to the legacy
// hardcoded mapping below, so an unconfigured stream keeps posting as before.
if (self::postViaRule($type, $data, $paymentId, $amount, $method, $memberId)) {
return;
}
// Determine debit account (where money goes) — checks if payment was at a sub-treasury
$treasuryId = isset($data['treasury_id']) ? (int) $data['treasury_id'] : null;
if ($treasuryId === null && $paymentId > 0) {
......@@ -113,6 +120,98 @@ final class AccountingIntegrationService
}
}
/**
* Route a payment through the configurable posting engine.
*
* @return bool true when the engine handled it; false to fall through to legacy.
*/
private static function postViaRule(
string $type,
array $data,
int $paymentId,
string $amount,
string $method,
int $memberId
): bool {
if ($type === '') {
return false;
}
$db = App::getInstance()->db();
if ($db === null) {
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;
$receiptNumber = '';
if ($payment && !empty($payment['receipt_id'])) {
$receipt = $db->selectOne("SELECT receipt_number FROM receipts WHERE id = ?", [(int) $payment['receipt_id']]);
$receiptNumber = $receipt['receipt_number'] ?? '';
}
$description = 'تحصيل ' . self::getPaymentTypeLabel($type);
if ($receiptNumber !== '') {
$description .= ' — إيصال ' . $receiptNumber;
}
$treasuryId = isset($data['treasury_id']) && $data['treasury_id'] ? (int) $data['treasury_id'] : null;
if ($treasuryId === null && $payment && !empty($payment['treasury_id'])) {
$treasuryId = (int) $payment['treasury_id'];
}
$result = \App\Modules\Accounting\Services\Revenue\RevenuePostingEngine::post($streamCode, [
'amount' => $amount,
'entry_date' => $payment['payment_date'] ?? date('Y-m-d'),
'payment_method' => $method,
'treasury_id' => $treasuryId,
'branch_id' => $data['branch_id'] ?? null,
'member_id' => $memberId,
'reference_type' => 'payment',
'reference_id' => $paymentId,
'reference_number' => $receiptNumber,
'source_module' => 'payments',
'description_ar' => $description,
'description_en' => 'Payment collection — ' . $type,
'period_months' => $data['period_months'] ?? null,
]);
if (!$result['success']) {
// The rule exists but could not produce a valid entry. Do NOT fall back —
// 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;
}
/**
* Auto-reverse journal entry when a payment is voided.
*/
......@@ -124,6 +223,13 @@ final class AccountingIntegrationService
$entry = \App\Modules\Accounting\Models\JournalEntry::findByReference('payment', $paymentId);
if ($entry && $entry->isPosted()) {
JournalService::reverseEntry((int) $entry->id, $reason);
// Drop the unrecognised tail of any deferral this entry created.
try {
\App\Modules\Accounting\Services\Revenue\RevenueRecognitionService::cancelForEntry((int) $entry->id);
} catch (\Throwable $e) {
Logger::error('Deferral cancellation failed: ' . $e->getMessage());
}
}
}
......
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Services\JournalService;
/**
* Deferred revenue amortisation — EAS 48 / IFRS 15.
*
* A club membership or annual subscription is a series of distinct services
* transferred over time, so the revenue is earned ratably over the period, not on
* the day the cash arrives. Collecting 12,000 in January for a Jan–Dec subscription
* earns 1,000 in January; the other 11,000 is a liability until it is served.
*
* On collection: Dr Cash 12,000 | Cr Deferred Revenue 12,000
* Each month: Dr Deferred Revenue 1,000 | Cr Subscription Revenue 1,000
*
* schedule() lays down the rows. run() posts one period's worth.
*/
final class RevenueRecognitionService
{
private const SCALE = 2;
/**
* Create the amortisation rows for the deferrals produced by a posting.
*
* @param array $deferrals From RevenuePostingEngine::plan()['deferrals']
*/
public static function schedule(array $deferrals, array $ctx, int $originEntryId, ?int $streamId = null): void
{
$db = App::getInstance()->db();
$startDate = $ctx['service_start_date'] ?? $ctx['entry_date'] ?? date('Y-m-d');
$memberId = isset($ctx['member_id']) && (int) $ctx['member_id'] > 0 ? (int) $ctx['member_id'] : null;
foreach ($deferrals as $d) {
$months = max(1, (int) $d['months']);
$parts = RevenueAllocator::straightLine((string) $d['amount'], $months);
foreach ($parts as $i => $amount) {
if (bccomp($amount, '0.00', self::SCALE) === 0) {
continue;
}
$period = date('Y-m', strtotime($startDate . ' +' . $i . ' month'));
$db->insert('revenue_recognition_schedules', [
'stream_id' => $streamId,
'rule_line_id' => $d['rule_line_id'] ?? null,
'source_reference_type' => $ctx['reference_type'] ?? null,
'source_reference_id' => $ctx['reference_id'] ?? null,
'member_id' => $memberId,
'deferred_account_id' => (int) $d['deferred_account_id'],
'revenue_account_id' => (int) $d['revenue_account_id'],
'cost_center_id' => $d['cost_center_id'] ?? null,
'branch_id' => $d['branch_id'] ?? null,
'period' => $period,
'amount' => $amount,
'description_ar' => $d['description_ar'] ?? null,
'status' => 'pending',
'origin_entry_id' => $originEntryId,
]);
}
}
}
/**
* Recognise every pending row up to and including a period.
* One consolidated journal entry per (deferred account, revenue account) pair,
* so the GL does not fill with thousands of one-line entries.
*
* @param string $period YYYY-MM
* @return array{success:bool, entries:int, amount:string, rows:int, errors:array}
*/
public static function run(string $period, bool $dryRun = false): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT * FROM revenue_recognition_schedules
WHERE status = 'pending' AND period <= ?
ORDER BY deferred_account_id, revenue_account_id, cost_center_id, branch_id",
[$period]
);
if (empty($rows)) {
return ['success' => true, 'entries' => 0, 'amount' => '0.00', 'rows' => 0, 'errors' => []];
}
// Group so each entry is one pair of accounts.
$groups = [];
foreach ($rows as $r) {
$key = implode('|', [
(int) $r['deferred_account_id'],
(int) $r['revenue_account_id'],
(string) ($r['cost_center_id'] ?? ''),
(string) ($r['branch_id'] ?? ''),
]);
$groups[$key][] = $r;
}
$errors = [];
$entryCount = 0;
$totalAmount = '0.00';
$rowCount = 0;
// Post on the last day of the requested period so it lands in the right month.
$entryDate = date('Y-m-t', strtotime($period . '-01'));
foreach ($groups as $group) {
$sum = '0.00';
foreach ($group as $r) {
$sum = bcadd($sum, (string) $r['amount'], self::SCALE);
}
if (bccomp($sum, '0.00', self::SCALE) <= 0) {
continue;
}
$first = $group[0];
$totalAmount = bcadd($totalAmount, $sum, self::SCALE);
$rowCount += count($group);
if ($dryRun) {
$entryCount++;
continue;
}
$result = JournalService::createEntry([
'entry_date' => $entryDate,
'description_ar' => 'تحقق إيراد مؤجل — فترة ' . $period,
'description_en' => 'Deferred revenue recognition — ' . $period,
'reference_type' => 'revenue_recognition',
'reference_id' => null,
'reference_number' => $period,
'source_module' => 'accounting',
'branch_id' => $first['branch_id'] ?? null,
'cost_center_id' => $first['cost_center_id'] ?? null,
'is_auto_generated' => 1,
], [
[
'account_id' => (int) $first['deferred_account_id'],
'debit' => $sum,
'credit' => '0.00',
'description_ar' => 'تخفيض إيرادات مقدمة — ' . $period,
'cost_center_id' => $first['cost_center_id'] ?? null,
'branch_id' => $first['branch_id'] ?? null,
],
[
'account_id' => (int) $first['revenue_account_id'],
'debit' => '0.00',
'credit' => $sum,
'description_ar' => 'إيراد مستحق عن فترة ' . $period,
'cost_center_id' => $first['cost_center_id'] ?? null,
'branch_id' => $first['branch_id'] ?? null,
],
], true);
if (!$result['success']) {
$errors[] = $result['error'] ?? 'فشل قيد التحقق';
Logger::error('Revenue recognition failed', ['period' => $period, 'error' => $result['error'] ?? '']);
continue;
}
$entryCount++;
$ids = array_map(static fn(array $r): int => (int) $r['id'], $group);
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$db->query(
"UPDATE revenue_recognition_schedules
SET status = 'recognized', journal_entry_id = ?, recognized_at = ?
WHERE id IN ({$placeholders})",
array_merge([(int) $result['journal_entry_id'], date('Y-m-d H:i:s')], $ids)
);
}
return [
'success' => empty($errors),
'entries' => $entryCount,
'amount' => $totalAmount,
'rows' => $rowCount,
'errors' => $errors,
];
}
/**
* Cancel the unrecognised remainder of a deferral — e.g. the collection entry
* that created it was voided. Already-recognised periods stay; you reverse those
* through the GL, you do not delete history.
*/
public static function cancelForEntry(int $originEntryId): int
{
$db = App::getInstance()->db();
$db->query(
"UPDATE revenue_recognition_schedules
SET status = 'cancelled'
WHERE origin_entry_id = ? AND status = 'pending'",
[$originEntryId]
);
$row = $db->selectOne(
"SELECT COUNT(*) AS n FROM revenue_recognition_schedules WHERE origin_entry_id = ? AND status = 'cancelled'",
[$originEntryId]
);
return (int) ($row['n'] ?? 0);
}
/** Outstanding deferred revenue by period — the liability roll-forward. */
public static function outstanding(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT s.period,
COUNT(*) AS rows_count,
SUM(s.amount) AS amount,
coa.account_code AS deferred_code,
coa.name_ar AS deferred_name
FROM revenue_recognition_schedules s
JOIN chart_of_accounts coa ON coa.id = s.deferred_account_id
WHERE s.status = 'pending'
GROUP BY s.period, coa.account_code, coa.name_ar
ORDER BY s.period ASC"
);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>فحص حالة ترحيل الإيرادات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/revenue-mapping" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى توزيع الإيرادات</a>
<h2 style="margin:6px 0 4px;">فحص حالة ترحيل الإيرادات</h2>
<p style="margin:0;color:#6B7280;font-size:13px;">
كل ما هو مكسور أو مجمَّع أو غير مربوط في ترحيل الإيرادات — من واقع بيانات النظام الفعلية.
</p>
</div>
<!-- ══ 1. Accounts that cannot be posted to ══ -->
<?php if (!empty($legacyBroken)): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #DC2626;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;color:#991B1B;">حسابات مُعرَّفة في الكود ولا تقبل الترحيل</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
محرك القيود يرفض الترحيل إلى حساب رئيسي. أي قيد يستهدف هذه الحسابات يفشل دون رسالة للمستخدم.
</div>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr><th>الثابت في الكود</th><th>الحساب</th><th>الاسم</th><th>المشكلة</th><th>الأثر</th></tr>
</thead>
<tbody>
<?php foreach ($legacyBroken as $b): ?>
<tr>
<td style="direction:ltr;text-align:right;font-family:monospace;font-size:12px;"><?= e($b['const']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($b['code']) ?></td>
<td><?= e($b['name']) ?></td>
<td><span class="badge badge-danger"><?= e($b['issue']) ?></span></td>
<td style="font-size:12px;color:#6B7280;"><?= e($b['label']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ 2. Rules pointing at unpostable accounts ══ -->
<?php if (!empty($badAccounts)): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #DC2626;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;color:#991B1B;">قواعد توزيع تشير إلى حسابات لا تقبل الترحيل</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>مصدر الإيراد</th><th>الحساب</th><th>الاسم</th><th>السبب</th><th></th></tr></thead>
<tbody>
<?php foreach ($badAccounts as $b): ?>
<tr>
<td><?= e($b['stream_name']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($b['account_code']) ?></td>
<td><?= e($b['name_ar']) ?></td>
<td><span class="badge badge-danger"><?= (int) $b['is_header'] === 1 ? 'حساب رئيسي' : 'غير نشط' ?></span></td>
<td><a href="/accounting/revenue-mapping/<?= (int) $b['stream_id'] ?>/edit" class="btn btn-sm btn-primary">تصحيح</a></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ 3. What is sitting in the catch-all ══ -->
<?php if (!empty($catchAll)): ?>
<?php
$catchTotal = '0.00';
$catchCount = 0;
foreach ($catchAll as $c) {
$catchTotal = bcadd($catchTotal, (string) $c['total'], 2);
$catchCount += (int) $c['n'];
}
?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #D97706;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;color:#92400E;">ما هو مُرحَّل فعليًا إلى «٤١٠٥١٥ — إيرادات متنوعه»</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
إجمالي <strong style="color:#92400E;"><?= money($catchTotal) ?></strong> جنيه
على <?= number_format($catchCount) ?> عملية مجمَّعة في حساب واحد.
</div>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>نوع الدفعة</th><th>عدد العمليات</th><th>الإجمالي</th><th>النسبة</th></tr></thead>
<tbody>
<?php foreach ($catchAll as $c): ?>
<?php $pct = bccomp($catchTotal, '0.00', 2) > 0 ? (float) bcdiv(bcmul((string) $c['total'], '100', 4), $catchTotal, 2) : 0.0; ?>
<tr>
<td style="direction:ltr;text-align:right;font-family:monospace;font-size:12px;"><?= e($c['payment_type']) ?></td>
<td><?= number_format((int) $c['n']) ?></td>
<td style="font-weight:600;"><?= money($c['total']) ?></td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<div style="flex:1;height:6px;background:#F3F4F6;border-radius:3px;overflow:hidden;max-width:120px;">
<div style="height:100%;width:<?= min(100, $pct) ?>%;background:#D97706;"></div>
</div>
<span style="font-size:11px;color:#6B7280;"><?= number_format($pct, 1) ?>%</span>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ 4. Payment types with no rule ══ -->
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">أنواع المدفوعات في البيانات الفعلية وحالة ربطها</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>نوع الدفعة</th><th>عدد العمليات</th><th>الإجمالي</th><th>مصدر الإيراد</th><th>الحالة</th><th></th></tr></thead>
<tbody>
<?php foreach ($unmapped as $u): ?>
<tr>
<td style="direction:ltr;text-align:right;font-family:monospace;font-size:12px;"><?= e($u['payment_type']) ?></td>
<td><?= number_format((int) $u['n']) ?></td>
<td style="font-weight:600;"><?= money($u['total']) ?></td>
<td><?= e($u['stream_name'] ?? '—') ?></td>
<td>
<?php if (empty($u['stream_id'])): ?>
<span class="badge badge-danger">لا يوجد مصدر</span>
<?php elseif (!$u['has_rule']): ?>
<span class="badge badge-warning">بدون قاعدة توزيع</span>
<?php else: ?>
<span class="badge badge-success">مربوط</span>
<?php endif; ?>
</td>
<td>
<?php if (!empty($u['stream_id'])): ?>
<a href="/accounting/revenue-mapping/<?= (int) $u['stream_id'] ?>/edit" class="btn btn-sm btn-outline">فتح</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- ══ 5. Recent posting failures ══ -->
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">آخر حالات فشل الترحيل</h3>
</div>
<?php if (empty($failures)): ?>
<div style="padding:30px;text-align:center;color:#6B7280;">لا توجد حالات فشل مسجَّلة</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>التاريخ</th><th>المصدر</th><th>المبلغ</th><th>الحالة</th><th>الرسالة</th></tr></thead>
<tbody>
<?php foreach ($failures as $f): ?>
<tr>
<td style="font-size:12px;color:#6B7280;"><?= e($f['created_at']) ?></td>
<td><?= e($f['stream_name'] ?? '—') ?></td>
<td><?= money($f['gross_amount']) ?></td>
<td><span class="badge badge-danger"><?= e($f['outcome']) ?></span></td>
<td style="font-size:12px;color:#991B1B;"><?= e($f['message'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الإيراد المؤجل<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/revenue-mapping" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى توزيع الإيرادات</a>
<h2 style="margin:6px 0 4px;">الإيراد المؤجل وتحققه</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:760px;">
الاشتراك السنوي المحصَّل مقدمًا لا يُعد إيرادًا كاملًا في شهر التحصيل. يُسجَّل التزامًا
(«إيرادات مدفوعة مقدمًا») ويتحقق شهريًا بالتساوي طوال مدة الخدمة —
معيار المحاسبة المصري رقم ٤٨ / IFRS 15.
</p>
</div>
<?php
$totalPending = '0.00';
foreach ($outstanding as $o) {
$totalPending = bcadd($totalPending, (string) $o['amount'], 2);
}
?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px;">
<div class="card" style="padding:14px 16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">إجمالي الإيراد المؤجل القائم</div>
<div style="font-size:22px;font-weight:700;color:#7C3AED;"><?= money($totalPending) ?></div>
</div>
<div class="card" style="padding:14px 16px;">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;">يستحق حتى <?= e($period) ?></div>
<div style="font-size:22px;font-weight:700;color:#059669;"><?= money($preview['amount']) ?></div>
<div style="font-size:11px;color:#6B7280;margin-top:3px;"><?= (int) $preview['rows'] ?> استحقاق في <?= (int) $preview['entries'] ?> قيد</div>
</div>
</div>
<div class="card" style="margin-bottom:16px;">
<div style="padding:16px 18px;">
<form method="GET" action="/accounting/revenue-mapping/recognition" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin-bottom:14px;">
<div>
<label class="form-label">الفترة</label>
<input type="month" name="period" class="form-input" value="<?= e($period) ?>" dir="ltr">
</div>
<div><button type="submit" class="btn btn-outline">معاينة</button></div>
</form>
<?php if (can('accounting.revenue_mapping.manage') && bccomp((string) $preview['amount'], '0.00', 2) > 0): ?>
<form method="POST" action="/accounting/revenue-mapping/recognition/run"
onsubmit="return confirm('سيتم ترحيل قيود تحقق الإيراد حتى فترة <?= e($period) ?>. متابعة؟');">
<?= csrf_field() ?>
<input type="hidden" name="period" value="<?= e($period) ?>">
<button type="submit" class="btn btn-primary">
ترحيل تحقق الإيراد حتى <?= e($period) ?><?= money($preview['amount']) ?>
</button>
</form>
<?php elseif (bccomp((string) $preview['amount'], '0.00', 2) <= 0): ?>
<div style="color:#6B7280;font-size:13px;">لا توجد استحقاقات معلَّقة حتى هذه الفترة.</div>
<?php endif; ?>
</div>
</div>
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">جدول الاستحقاق القائم</h3></div>
<?php if (empty($outstanding)): ?>
<div style="padding:30px;text-align:center;color:#6B7280;">
لا يوجد إيراد مؤجل — فعِّل بندًا من نوع «إيراد مؤجل» في أحد مصادر الإيراد أولًا.
</div>
<?php else: ?>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الفترة</th><th>حساب الإيراد المؤجل</th><th>عدد الاستحقاقات</th><th>المبلغ</th></tr></thead>
<tbody>
<?php foreach ($outstanding as $o): ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($o['period']) ?></td>
<td><span style="direction:ltr;color:#6B7280;font-size:12px;"><?= e($o['deferred_code']) ?></span> <?= e($o['deferred_name']) ?></td>
<td><?= number_format((int) $o['rows_count']) ?></td>
<td style="font-weight:600;"><?= money($o['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php if (!empty($recent)): ?>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">آخر ما تم تحققه</h3></div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead><tr><th>الفترة</th><th>من (مؤجل)</th><th>إلى (إيراد)</th><th>المبلغ</th><th>تاريخ الترحيل</th><th>القيد</th></tr></thead>
<tbody>
<?php foreach ($recent as $r): ?>
<tr>
<td style="direction:ltr;text-align:right;"><?= e($r['period']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($r['deferred_code'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($r['revenue_code'] ?? '') ?></td>
<td style="font-weight:600;"><?= money($r['amount']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['recognized_at'] ?? '') ?></td>
<td>
<?php if (!empty($r['journal_entry_id'])): ?>
<a href="/accounting/journal-entries/<?= (int) $r['journal_entry_id'] ?>" class="btn btn-sm btn-ghost">عرض</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الملفات الضريبية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/revenue-mapping" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع إلى توزيع الإيرادات</a>
<h2 style="margin:6px 0 4px;">الملفات الضريبية</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:720px;">
الضريبة ليست جزءًا من الإيراد — هي التزام محصَّل لصالح مصلحة الضرائب. الفارق بين
«شاملة السعر» و«تُضاف على السعر» يغيّر المبلغ فعليًا: ١١٤٠ شاملة ١٤٪ إيرادها ١٠٠٠ وضريبتها ١٤٠،
بينما ١٤٪ محسوبة كنسبة من ١١٤٠ تعطي ١٥٩٫٦٠ — وهو خطأ.
</p>
</div>
<?php if (can('accounting.revenue_mapping.manage')): ?>
<div class="card" style="margin-bottom:16px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">إضافة ملف ضريبي</h3></div>
<div style="padding:16px 18px;">
<form method="POST" action="/accounting/revenue-mapping/tax-profiles">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;">
<div>
<label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="tax_code" class="form-input" required dir="ltr" placeholder="VAT14" style="text-transform:uppercase;">
</div>
<div>
<label class="form-label">الاسم <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required placeholder="ضريبة قيمة مضافة 14%">
</div>
<div>
<label class="form-label">المعالجة <span style="color:#DC2626;">*</span></label>
<select name="treatment" class="form-select" required>
<option value="standard">خاضعة بالسعر العام</option>
<option value="table">سلع وخدمات الجدول</option>
<option value="zero_rated">بسعر صفر (الخصم مسموح)</option>
<option value="exempt">معفاة (لا خصم مدخلات)</option>
<option value="out_of_scope">خارج نطاق الضريبة</option>
</select>
</div>
<div>
<label class="form-label">النسبة %</label>
<input type="number" name="rate" class="form-input" step="0.0001" min="0" value="14" dir="ltr" style="text-align:right;">
</div>
<div>
<label class="form-label">علاقة السعر بالضريبة</label>
<select name="is_price_inclusive" class="form-select">
<option value="1">السعر شامل الضريبة (تُستخرج منه)</option>
<option value="0">الضريبة تُضاف على السعر</option>
</select>
</div>
<div>
<label class="form-label">حساب ضريبة المخرجات</label>
<input type="text" class="form-input" id="tax-acct-search" placeholder="ابحث بالكود أو الاسم">
<input type="hidden" name="output_tax_account_id" id="tax-acct-id">
<div id="tax-acct-results"></div>
</div>
<div>
<label class="form-label">السند القانوني</label>
<input type="text" name="legal_reference" class="form-input" placeholder="قانون 67 لسنة 2016">
</div>
<div>
<label class="form-label">ساري من</label>
<input type="date" name="effective_from" class="form-input" value="<?= e(date('Y-m-d')) ?>">
</div>
</div>
<div style="margin-top:14px;"><button type="submit" class="btn btn-primary">حفظ</button></div>
</form>
</div>
</div>
<?php endif; ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr><th>الكود</th><th>الاسم</th><th>المعالجة</th><th>النسبة</th><th>علاقة السعر</th><th>حساب المخرجات</th><th>السند</th><th>الحالة</th></tr>
</thead>
<tbody>
<?php foreach ($profiles as $p): ?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($p['tax_code']) ?></td>
<td><?= e($p['name_ar']) ?></td>
<td>
<?php
$labels = [
'standard' => ['خاضعة', 'badge-primary'],
'table' => ['جدول', 'badge-info'],
'zero_rated' => ['صفر', 'badge-neutral'],
'exempt' => ['معفاة', 'badge-warning'],
'out_of_scope' => ['خارج النطاق', 'badge-neutral'],
];
[$lbl, $cls] = $labels[$p['treatment']] ?? ['—', 'badge-neutral'];
?>
<span class="badge <?= $cls ?>"><?= e($lbl) ?></span>
</td>
<td style="font-weight:600;"><?= rtrim(rtrim(number_format((float) $p['rate'], 4), '0'), '.') ?>%</td>
<td style="font-size:12px;"><?= (int) $p['is_price_inclusive'] === 1 ? 'شامل الضريبة' : 'تُضاف على السعر' ?></td>
<td style="font-size:12px;">
<?php if (!empty($p['output_code'])): ?>
<span style="direction:ltr;color:#6B7280;"><?= e($p['output_code']) ?></span> <?= e($p['output_name']) ?>
<?php else: ?>
<span class="badge badge-danger">غير محدد</span>
<?php endif; ?>
</td>
<td style="font-size:11px;color:#6B7280;"><?= e($p['legal_reference'] ?? '—') ?></td>
<td><span class="badge <?= (int) $p['is_active'] ? 'badge-success' : 'badge-neutral' ?>"><?= (int) $p['is_active'] ? 'نشط' : 'موقف' ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<script>
(function () {
var input = document.getElementById('tax-acct-search');
var hidden = document.getElementById('tax-acct-id');
var results = document.getElementById('tax-acct-results');
if (!input) return;
var timer = null;
input.addEventListener('input', function () {
clearTimeout(timer);
var q = input.value.trim();
if (q.length < 2) { results.innerHTML = ''; return; }
timer = setTimeout(function () {
fetch('/accounting/revenue-mapping/search-accounts?q=' + encodeURIComponent(q) + '&type=liability')
.then(function (r) { return r.json(); })
.then(function (d) {
results.innerHTML = '';
var box = document.createElement('div');
box.style.cssText = 'border:1px solid #E5E7EB;border-radius:6px;margin-top:4px;max-height:200px;overflow:auto;background:#fff;';
(d.accounts || []).forEach(function (a) {
var row = document.createElement('div');
row.style.cssText = 'padding:6px 10px;cursor:pointer;font-size:12px;border-bottom:1px solid #F3F4F6;';
row.innerHTML = '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:80px;">' + a.account_code + '</span> ' + a.name_ar;
row.addEventListener('click', function () {
hidden.value = a.id;
input.value = a.account_code + ' — ' + a.name_ar;
results.innerHTML = '';
});
box.appendChild(row);
});
results.appendChild(box);
});
}, 220);
});
})();
</script>
<?php $__template->endSection(); ?>
......@@ -102,6 +102,10 @@ PermissionRegistry::register('accounting', [
// Letters of Guarantee
'accounting.guarantee.view' => ['ar' => 'عرض خطابات الضمان', 'en' => 'View Letters of Guarantee'],
'accounting.guarantee.manage' => ['ar' => 'إدارة خطابات الضمان', 'en' => 'Manage Letters of Guarantee'],
// Revenue Mapping (account determination)
'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'],
'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'],
]);
// ────────────────────────────────────────────────────────────
......@@ -119,6 +123,8 @@ MenuRegistry::register('accounting', [
'children' => [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/accounting', 'permission' => 'accounting.reports.view', 'order' => 1],
['label_ar' => 'دليل الحسابات', 'label_en' => 'Chart of Accounts', 'route' => '/accounting/chart-of-accounts', 'permission' => 'accounting.coa.view', 'order' => 2],
['label_ar' => 'توزيع الإيرادات', 'label_en' => 'Revenue Mapping', 'route' => '/accounting/revenue-mapping', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3],
['label_ar' => 'أنواع اليومية', 'label_en' => 'Journal Types', 'route' => '/accounting/journal-types', 'permission' => 'accounting.journal_type.view', 'order' => 4],
['label_ar' => 'السنوات المالية', 'label_en' => 'Fiscal Years', 'route' => '/accounting/fiscal-years', 'permission' => 'accounting.fiscal_year.view', 'order' => 5],
......
<?php
declare(strict_types=1);
use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
/**
* Bootstrap the revenue posting engine.
*
* Deliberately reproduces the CURRENT hardcoded posting behaviour exactly, so
* deploying this changes no reported number. Every mapping then becomes editable
* from /accounting/revenue-mapping, and the streams that are currently landing in
* a catch-all account are flagged for review rather than silently re-pointed —
* re-pointing them moves real revenue between accounts and is finance's call.
*/
return function (\App\Core\Database $db): void {
$now = date('Y-m-d H:i:s');
// ── 1. Missing postable accounts ─────────────────────────────────────
// 120301 العملاء and 230804 جاري مصلحة الضرائب are header accounts, and
// JournalService refuses to post to a header — which is why every AR and VAT
// posting has been failing silently. The tax family already has postable
// children (23080404 etc). Member receivables did not exist at all.
$ensureAccount = function (string $code, string $nameAr, string $nameEn, string $type, string $nature, string $parentCode) use ($db, $now): void {
$existing = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$code]);
if ($existing) {
return;
}
$parent = $db->selectOne("SELECT id, level FROM chart_of_accounts WHERE account_code = ?", [$parentCode]);
if (!$parent) {
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,
]);
};
$ensureAccount('120301004', 'أعضاء النادي (مدينون)', 'Club Members Receivable', 'asset', 'debit', '120301');
$ensureAccount('12041106', 'ضريبة القيمة المضافة — مدخلات', 'Input VAT', 'asset', 'debit', '120411');
// ── 2. Revenue streams ───────────────────────────────────────────────
RevenueStreamRegistry::sync($db);
// ── 3. Tax profiles ──────────────────────────────────────────────────
$accId = function (string $code) use ($db): ?int {
$row = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0", [$code]);
return $row ? (int) $row['id'] : null;
};
$outputVat = $accId('23080404'); // ضريبة القيمة المضافة — postable, correct
$inputVat = $accId('12041106');
$taxProfiles = [
[
'tax_code' => 'VAT14', 'name_ar' => 'ضريبة قيمة مضافة 14% (شاملة السعر)',
'name_en' => 'VAT 14% (price-inclusive)', 'treatment' => 'standard',
'rate' => '14.0000', 'is_price_inclusive' => 1,
'legal_reference' => 'قانون الضريبة على القيمة المضافة رقم 67 لسنة 2016',
],
[
'tax_code' => 'VAT14EX', 'name_ar' => 'ضريبة قيمة مضافة 14% (تُضاف على السعر)',
'name_en' => 'VAT 14% (price-exclusive)', 'treatment' => 'standard',
'rate' => '14.0000', 'is_price_inclusive' => 0,
'legal_reference' => 'قانون الضريبة على القيمة المضافة رقم 67 لسنة 2016',
],
[
'tax_code' => 'VAT_EXEMPT', 'name_ar' => 'معفاة من الضريبة',
'name_en' => 'VAT exempt', 'treatment' => 'exempt',
'rate' => '0.0000', 'is_price_inclusive' => 1,
'legal_reference' => 'مادة 26 — قانون 67 لسنة 2016 (لا يجوز خصم ضريبة المدخلات)',
],
[
'tax_code' => 'VAT_ZERO', 'name_ar' => 'خاضعة بسعر صفر',
'name_en' => 'Zero-rated', 'treatment' => 'zero_rated',
'rate' => '0.0000', 'is_price_inclusive' => 1,
'legal_reference' => 'قانون 67 لسنة 2016 — الخصم مسموح',
],
[
'tax_code' => 'OUT_OF_SCOPE', 'name_ar' => 'خارج نطاق الضريبة',
'name_en' => 'Out of scope', 'treatment' => 'out_of_scope',
'rate' => '0.0000', 'is_price_inclusive' => 1,
'legal_reference' => 'اشتراكات الأعضاء بالأندية — قانون الرياضة 71 لسنة 2017',
],
];
foreach ($taxProfiles as $tp) {
if ($db->selectOne("SELECT id FROM revenue_tax_profiles WHERE tax_code = ?", [$tp['tax_code']])) {
continue;
}
$db->insert('revenue_tax_profiles', $tp + [
'output_tax_account_id' => $outputVat,
'input_tax_account_id' => $inputVat,
'effective_from' => '2016-09-08',
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// ── 4. Default posting rules — reproduce today's behaviour exactly ───
$definitions = RevenueStreamRegistry::definitions();
// Streams whose current account is a catch-all or a demonstrably wrong account.
// Flagged, not changed.
$needsReview = [
'payment:separation_fee' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — يحتاج حسابًا مخصصًا',
'payment:death_fee' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — يحتاج حسابًا مخصصًا',
'payment:waiver_fee' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — أكبر مبلغ في الحساب المجمع',
'payment:sports_conversion' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه»',
'payment:foreign_membership_fee'=> 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — لا توجد قاعدة أصلًا في الكود',
'payment:early_settlement' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — لا توجد قاعدة أصلًا في الكود',
'payment:sports_membership_fee' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — لا توجد قاعدة أصلًا في الكود',
'payment:sports_subscription' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — لا توجد قاعدة أصلًا في الكود',
'payment:sa_registration_fee' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه» — لا توجد قاعدة أصلًا في الكود',
'payment:inventory_sale' => 'يُرحَّل حاليًا إلى «إيرادات متنوعه»',
'payment:divorce_fee' => 'يُرحَّل حاليًا إلى حساب «محل 1» (إيجار محل) — ربط خاطئ',
'payment:other' => 'حساب مجمع — راجع كل حالة',
];
foreach ($definitions as $code => $def) {
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if (!$stream) {
continue;
}
$streamId = (int) $stream['id'];
if ($db->selectOne("SELECT id FROM revenue_posting_rules WHERE stream_id = ?", [$streamId])) {
continue;
}
$accountId = $accId($def['legacy_account'] ?? '');
if ($accountId === null) {
continue;
}
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => 1,
'name_ar' => 'القاعدة الافتراضية — مطابقة للسلوك الحالي',
'debit_source' => 'auto_treasury',
'tax_profile_id' => null,
'status' => 'active',
'effective_from' => '2000-01-01',
'notes' => $needsReview[$code] ?? null,
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => 'revenue',
'allocation_method' => 'remainder',
'account_id' => $accountId,
'recognition_method'=> 'immediate',
'description_ar' => $def['name_ar'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
if (isset($needsReview[$code])) {
$db->update('revenue_streams', [
'notes' => $needsReview[$code],
'updated_at' => $now,
], '`id` = ?', [$streamId]);
}
}
// Streams discovered from live data but never declared get no rule — they show
// as "غير مربوط" in the UI so nobody can miss them.
};
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