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
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\App;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Accounting\Services\Revenue\RevenuePostingEngine;
use App\Modules\Accounting\Services\Revenue\RevenueRecognitionService;
use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
/**
* توزيع الإيرادات على الحسابات — the single screen that controls where every
* piastre collected anywhere in the ERP lands in the general ledger.
*/
class RevenueMappingController extends Controller
{
// ────────────────────────────────────────────────────────────
// Streams list
// ────────────────────────────────────────────────────────────
public function index(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
$category = (string) $request->get('category', '');
$status = (string) $request->get('status', '');
$search = trim((string) $request->get('q', ''));
$where = ['s.is_active = 1'];
$params = [];
if ($category !== '') {
$where[] = 's.category = ?';
$params[] = $category;
}
if ($search !== '') {
$where[] = '(s.name_ar LIKE ? OR s.stream_code LIKE ? OR s.name_en LIKE ?)';
$like = '%' . $search . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
$streams = $db->select(
"SELECT 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) . "
ORDER BY s.category ASC, s.name_ar ASC",
$params
);
// Attach the target accounts + live volume so the list is decision-ready.
foreach ($streams as &$s) {
$s['lines'] = [];
if (!empty($s['rule_id'])) {
$s['lines'] = $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name, coa.is_header
FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order ASC",
[(int) $s['rule_id']]
);
}
$s['volume'] = ['n' => 0, 'total' => '0.00'];
if ($s['source_module'] === 'payments' && !empty($s['source_key'])) {
$v = $db->selectOne(
"SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0) AS total
FROM payments WHERE payment_type = ? AND is_voided = 0",
[$s['source_key']]
);
$s['volume'] = ['n' => (int) ($v['n'] ?? 0), 'total' => (string) ($v['total'] ?? '0.00')];
}
$s['is_mapped'] = !empty($s['rule_id']);
$s['is_catchall'] = false;
$s['has_header'] = false;
foreach ($s['lines'] as $l) {
if ($l['account_code'] === '410515') {
$s['is_catchall'] = true;
}
if ((int) $l['is_header'] === 1) {
$s['has_header'] = true;
}
}
}
unset($s);
if ($status === 'unmapped') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => !$s['is_mapped']));
} elseif ($status === 'catchall') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => $s['is_catchall']));
} elseif ($status === 'split') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => ((int) $s['line_count']) > 1));
} elseif ($status === 'review') {
$streams = array_values(array_filter($streams, static fn(array $s): bool => !empty($s['notes'])));
}
return $this->view('Accounting.Views.revenue_mapping.index', [
'streams' => $streams,
'category' => $category,
'status' => $status,
'search' => $search,
'categories' => self::categories(),
'summary' => $this->summary(),
]);
}
// ────────────────────────────────────────────────────────────
// Rule builder for one stream
// ────────────────────────────────────────────────────────────
public function edit(Request $request, string $id): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [(int) $id]);
if (!$stream) {
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
}
$rule = RevenuePostingEngine::resolveRule((int) $id, date('Y-m-d'));
$lines = [];
if ($rule) {
$lines = $db->select(
"SELECT l.*, coa.account_code, coa.name_ar AS account_name
FROM revenue_posting_rule_lines l
JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE l.rule_id = ? AND l.is_active = 1
ORDER BY l.sort_order ASC",
[(int) $rule['id']]
);
}
$history = $db->select(
"SELECT r.*, e.full_name_ar AS activated_by_name
FROM revenue_posting_rules r
LEFT JOIN employees e ON e.id = r.activated_by
WHERE r.stream_id = ?
ORDER BY r.version DESC
LIMIT 20",
[(int) $id]
);
$debitLabel = '';
if ($rule && !empty($rule['debit_account_id'])) {
$acc = $db->selectOne(
"SELECT account_code, name_ar FROM chart_of_accounts WHERE id = ?",
[(int) $rule['debit_account_id']]
);
if ($acc) {
$debitLabel = $acc['account_code'] . ' — ' . $acc['name_ar'];
}
}
return $this->view('Accounting.Views.revenue_mapping.edit', [
'stream' => $stream,
'rule' => $rule,
'lines' => $lines,
'history' => $history,
'debitLabel' => $debitLabel,
'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"),
'branches' => $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar"),
]);
}
/**
* 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
* produced them.
*/
public function update(Request $request, string $id): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$streamId = (int) $id;
$stream = $db->selectOne("SELECT * FROM revenue_streams WHERE id = ?", [$streamId]);
if (!$stream) {
return $this->redirect('/accounting/revenue-mapping')->withError('مصدر الإيراد غير موجود');
}
$payload = $this->parseLines($request);
if (!empty($payload['errors'])) {
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError(implode(' — ', $payload['errors']));
}
$effectiveFrom = (string) $request->post('effective_from', date('Y-m-d'));
if ($effectiveFrom === '') {
$effectiveFrom = date('Y-m-d');
}
$current = RevenuePostingEngine::resolveRule($streamId, date('Y-m-d'));
$nextVersion = 1;
$maxRow = $db->selectOne("SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ?", [$streamId]);
if ($maxRow && $maxRow['v'] !== null) {
$nextVersion = ((int) $maxRow['v']) + 1;
}
$employee = App::getInstance()->currentEmployee();
$employeeId = $employee ? (int) $employee->id : null;
$now = date('Y-m-d H:i:s');
$taxProfileId = $request->post('tax_profile_id');
$taxProfileId = ($taxProfileId !== null && $taxProfileId !== '') ? (int) $taxProfileId : null;
$debitSource = (string) $request->post('debit_source', 'auto_treasury');
$debitAccountId = $request->post('debit_account_id');
$debitAccountId = ($debitAccountId !== null && $debitAccountId !== '') ? (int) $debitAccountId : null;
if ($debitSource === 'auto_treasury') {
$debitAccountId = null;
} elseif ($debitAccountId === null) {
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError('اختر الحساب المدين عند استخدام حساب ثابت');
}
$costCenterId = $request->post('cost_center_id');
$costCenterId = ($costCenterId !== null && $costCenterId !== '') ? (int) $costCenterId : null;
$branchId = $request->post('branch_id');
$branchId = ($branchId !== null && $branchId !== '') ? (int) $branchId : null;
$paymentMethod = (string) $request->post('payment_method', '');
$paymentMethod = $paymentMethod !== '' ? $paymentMethod : null;
$db->beginTransaction();
try {
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => $nextVersion,
'name_ar' => $request->post('name_ar') ?: ('إصدار ' . $nextVersion),
'branch_id' => $branchId,
'payment_method' => $paymentMethod,
'debit_account_id' => $debitAccountId,
'debit_source' => $debitSource,
'tax_profile_id' => $taxProfileId,
'cost_center_id' => $costCenterId,
'status' => 'active',
'effective_from' => $effectiveFrom,
'notes' => $request->post('notes'),
'created_at' => $now,
'updated_at' => $now,
'created_by' => $employeeId,
'activated_at' => $now,
'activated_by' => $employeeId,
]);
foreach ($payload['lines'] as $i => $line) {
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => $i + 1,
'line_type' => $line['line_type'],
'allocation_method' => $line['allocation_method'],
'fixed_amount' => $line['fixed_amount'],
'percentage' => $line['percentage'],
'percentage_base' => $line['percentage_base'],
'account_id' => $line['account_id'],
'cost_center_id' => $line['cost_center_id'],
'recognition_method' => $line['recognition_method'],
'recognition_months' => $line['recognition_months'],
'recognized_account_id' => $line['recognized_account_id'],
'description_ar' => $line['description_ar'],
'max_amount' => $line['max_amount'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// Supersede the rule this one replaces (same scope only).
if ($current) {
$sameScope = ((int) ($current['branch_id'] ?? 0)) === ((int) ($branchId ?? 0))
&& ((string) ($current['payment_method'] ?? '')) === ((string) ($paymentMethod ?? ''));
if ($sameScope) {
$db->update('revenue_posting_rules', [
'status' => 'superseded',
'effective_to' => date('Y-m-d', strtotime($effectiveFrom . ' -1 day')),
'superseded_by_id' => $ruleId,
'updated_at' => $now,
'updated_by' => $employeeId,
], '`id` = ?', [(int) $current['id']]);
}
}
// Once explicitly configured the review flag is no longer meaningful.
$db->update('revenue_streams', ['notes' => null, 'updated_at' => $now], '`id` = ?', [$streamId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withError('فشل حفظ القاعدة: ' . $e->getMessage());
}
return $this->redirect('/accounting/revenue-mapping/' . $streamId . '/edit')
->withSuccess('تم حفظ الإصدار ' . $nextVersion . ' وتفعيله اعتبارًا من ' . $effectiveFrom);
}
// ────────────────────────────────────────────────────────────
// Live simulation — what the entry would look like
// ────────────────────────────────────────────────────────────
public function simulate(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$amount = (string) $request->input('amount', '0');
$lines = $this->parseLines($request, true);
$taxProfile = null;
$taxProfileId = $request->input('tax_profile_id');
if ($taxProfileId !== null && $taxProfileId !== '') {
$taxProfile = App::getInstance()->db()->selectOne(
"SELECT * FROM revenue_tax_profiles WHERE id = ?",
[(int) $taxProfileId]
);
}
if (!empty($lines['errors'])) {
return $this->json(['success' => false, 'errors' => $lines['errors']]);
}
$alloc = \App\Modules\Accounting\Services\Revenue\RevenueAllocator::allocate(
$amount,
$lines['lines'],
$taxProfile
);
// Decorate with account labels for display.
$db = App::getInstance()->db();
foreach ($alloc['allocations'] as &$a) {
$acc = $db->selectOne(
"SELECT account_code, name_ar, is_header FROM chart_of_accounts WHERE id = ?",
[(int) $a['account_id']]
);
$a['account_code'] = $acc['account_code'] ?? '';
$a['account_name'] = $acc['name_ar'] ?? '';
$a['is_header'] = (int) ($acc['is_header'] ?? 0);
if ($a['is_header'] === 1) {
$alloc['errors'][] = 'الحساب ' . $a['account_code'] . ' حساب رئيسي ولا يقبل الترحيل';
}
}
unset($a);
if ($alloc['tax_account_id']) {
$acc = $db->selectOne(
"SELECT account_code, name_ar FROM chart_of_accounts WHERE id = ?",
[(int) $alloc['tax_account_id']]
);
$alloc['tax_account'] = ($acc['account_code'] ?? '') . ' — ' . ($acc['name_ar'] ?? '');
}
return $this->json(['success' => true, 'result' => $alloc]);
}
// ────────────────────────────────────────────────────────────
// Tax profiles
// ────────────────────────────────────────────────────────────
public function taxProfiles(): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
return $this->view('Accounting.Views.revenue_mapping.tax_profiles', [
'profiles' => $db->select(
"SELECT tp.*, coa.account_code AS output_code, coa.name_ar AS output_name
FROM revenue_tax_profiles tp
LEFT JOIN chart_of_accounts coa ON coa.id = tp.output_tax_account_id
ORDER BY tp.tax_code"
),
]);
}
public function storeTaxProfile(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$data = $this->validate($request->all(), [
'tax_code' => 'required',
'name_ar' => 'required',
'treatment' => 'required',
]);
$code = strtoupper(preg_replace('/[^A-Za-z0-9_]/', '', $data['tax_code']) ?? '');
if ($code === '') {
return $this->redirect('/accounting/revenue-mapping/tax-profiles')->withError('كود الضريبة غير صالح');
}
if ($db->selectOne("SELECT id FROM revenue_tax_profiles WHERE tax_code = ?", [$code])) {
return $this->redirect('/accounting/revenue-mapping/tax-profiles')->withError('كود الضريبة مستخدم بالفعل');
}
$outputId = $request->post('output_tax_account_id');
$outputId = ($outputId !== null && $outputId !== '') ? (int) $outputId : null;
if ($outputId !== null) {
$acc = $db->selectOne("SELECT is_header FROM chart_of_accounts WHERE id = ?", [$outputId]);
if ($acc && (int) $acc['is_header'] === 1) {
return $this->redirect('/accounting/revenue-mapping/tax-profiles')
->withError('حساب الضريبة المختار حساب رئيسي ولا يقبل الترحيل — اختر حسابًا فرعيًا');
}
}
$db->insert('revenue_tax_profiles', [
'tax_code' => $code,
'name_ar' => $data['name_ar'],
'name_en' => $request->post('name_en'),
'treatment' => $data['treatment'],
'rate' => (string) $request->post('rate', '0'),
'is_price_inclusive' => (int) $request->post('is_price_inclusive', 1),
'output_tax_account_id' => $outputId,
'legal_reference' => $request->post('legal_reference'),
'effective_from' => $request->post('effective_from') ?: date('Y-m-d'),
'is_active' => 1,
]);
return $this->redirect('/accounting/revenue-mapping/tax-profiles')->withSuccess('تم إنشاء الملف الضريبي');
}
public function updateTaxProfile(Request $request, string $id): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$db = App::getInstance()->db();
$outputId = $request->post('output_tax_account_id');
$outputId = ($outputId !== null && $outputId !== '') ? (int) $outputId : null;
$db->update('revenue_tax_profiles', [
'name_ar' => $request->post('name_ar'),
'rate' => (string) $request->post('rate', '0'),
'is_price_inclusive' => (int) $request->post('is_price_inclusive', 1),
'treatment' => (string) $request->post('treatment', 'standard'),
'output_tax_account_id' => $outputId,
'legal_reference' => $request->post('legal_reference'),
'is_active' => (int) $request->post('is_active', 1),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/accounting/revenue-mapping/tax-profiles')->withSuccess('تم تحديث الملف الضريبي');
}
// ────────────────────────────────────────────────────────────
// Deferred revenue
// ────────────────────────────────────────────────────────────
public function recognition(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
$period = (string) $request->get('period', date('Y-m'));
return $this->view('Accounting.Views.revenue_mapping.recognition', [
'period' => $period,
'outstanding' => RevenueRecognitionService::outstanding(),
'preview' => RevenueRecognitionService::run($period, true),
'recent' => $db->select(
"SELECT s.*, coa.account_code AS deferred_code, coa2.account_code AS revenue_code
FROM revenue_recognition_schedules s
LEFT JOIN chart_of_accounts coa ON coa.id = s.deferred_account_id
LEFT JOIN chart_of_accounts coa2 ON coa2.id = s.revenue_account_id
WHERE s.status = 'recognized'
ORDER BY s.recognized_at DESC LIMIT 25"
),
]);
}
public function runRecognition(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$period = (string) $request->post('period', date('Y-m'));
if (!preg_match('/^\d{4}-\d{2}$/', $period)) {
return $this->redirect('/accounting/revenue-mapping/recognition')->withError('صيغة الفترة غير صحيحة');
}
$result = RevenueRecognitionService::run($period, false);
if (!$result['success']) {
return $this->redirect('/accounting/revenue-mapping/recognition?period=' . $period)
->withError('تم الترحيل جزئيًا: ' . implode(' | ', $result['errors']));
}
return $this->redirect('/accounting/revenue-mapping/recognition?period=' . $period)
->withSuccess('تم ترحيل ' . $result['entries'] . ' قيد بإجمالي ' . money($result['amount']) . ' عن ' . $result['rows'] . ' استحقاق');
}
// ────────────────────────────────────────────────────────────
// Diagnostics — what is currently wrong
// ────────────────────────────────────────────────────────────
public function diagnostics(): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
// Payment types transacting with no stream, or a stream with no rule.
$unmapped = $db->select(
"SELECT p.payment_type,
COUNT(*) AS n,
COALESCE(SUM(p.amount),0) AS total,
s.id AS stream_id,
s.name_ar AS stream_name
FROM payments p
LEFT JOIN revenue_streams s
ON s.source_module = 'payments' AND s.source_key = p.payment_type
WHERE p.is_voided = 0
GROUP BY p.payment_type, s.id, s.name_ar
ORDER BY total DESC"
);
foreach ($unmapped as &$u) {
$u['has_rule'] = false;
if (!empty($u['stream_id'])) {
$u['has_rule'] = RevenuePostingEngine::isConfigured((int) $u['stream_id']);
}
}
unset($u);
// What is sitting in the miscellaneous catch-all account, by payment type.
$catchAll = $db->select(
"SELECT p.payment_type, COUNT(*) AS n, SUM(jel.credit) AS total
FROM journal_entry_lines jel
JOIN chart_of_accounts coa ON coa.id = jel.account_id
JOIN journal_entries je ON je.id = jel.journal_entry_id
JOIN payments p ON p.id = je.reference_id AND je.reference_type = 'payment'
WHERE coa.account_code = '410515' AND jel.credit > 0
GROUP BY p.payment_type
ORDER BY total DESC"
);
// Accounts referenced by rules that cannot actually be posted to.
$badAccounts = $db->select(
"SELECT DISTINCT coa.account_code, coa.name_ar, coa.is_header, coa.is_active,
s.name_ar AS stream_name, s.id AS stream_id
FROM revenue_posting_rule_lines l
JOIN revenue_posting_rules r ON r.id = l.rule_id AND r.status = 'active'
JOIN revenue_streams s ON s.id = r.stream_id
JOIN chart_of_accounts coa ON coa.id = l.account_id
WHERE coa.is_header = 1 OR coa.is_active = 0"
);
// Legacy hardcoded constants that point at a header or missing account.
$legacyBroken = [];
$legacyMap = [
'ACCOUNTS_RECEIVABLE' => ['120301', 'حساب المدينين المستخدم في قيود الغرامات والأقساط'],
'TAX_PAYABLE' => ['230804', 'حساب الضرائب المستخدم في قيود الإيجارات والمرتبات'],
'INPUT_TAX' => ['120408', 'حساب ضريبة المدخلات'],
'DEFERRED_REVENUE' => ['230809', 'حساب الإيرادات المقدمة'],
'COGS' => ['3172', 'حساب تكلفة البضاعة المباعة'],
];
foreach ($legacyMap as $const => [$code, $label]) {
$acc = $db->selectOne(
"SELECT account_code, name_ar, is_header, is_active FROM chart_of_accounts WHERE account_code = ?",
[$code]
);
if (!$acc) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'الحساب غير موجود', 'name' => '—'];
} elseif ((int) $acc['is_header'] === 1) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'حساب رئيسي — الترحيل يفشل صامتًا', 'name' => $acc['name_ar']];
} elseif ((int) $acc['is_active'] === 0) {
$legacyBroken[] = ['const' => $const, 'code' => $code, 'label' => $label, 'issue' => 'حساب غير نشط', 'name' => $acc['name_ar']];
}
}
$failures = $db->select(
"SELECT l.*, s.name_ar AS stream_name
FROM revenue_posting_log l
LEFT JOIN revenue_streams s ON s.id = l.stream_id
WHERE l.outcome IN ('failed','fallback')
ORDER BY l.created_at DESC LIMIT 50"
);
return $this->view('Accounting.Views.revenue_mapping.diagnostics', [
'unmapped' => $unmapped,
'catchAll' => $catchAll,
'badAccounts' => $badAccounts,
'legacyBroken' => $legacyBroken,
'failures' => $failures,
'summary' => $this->summary(),
]);
}
/** Re-scan the ERP for chargeable things that have no stream yet. */
public function sync(): Response
{
$this->authorize('accounting.revenue_mapping.manage');
$result = RevenueStreamRegistry::sync();
$msg = 'تمت المزامنة — ' . $result['created'] . ' مصدر جديد';
if (!empty($result['discovered'])) {
$msg .= ' (مكتشفة من البيانات: ' . implode('، ', $result['discovered']) . ')';
}
return $this->redirect('/accounting/revenue-mapping')->withSuccess($msg);
}
/** Account picker used by the rule builder. */
public function searchAccounts(Request $request): Response
{
$this->authorize('accounting.revenue_mapping.view');
$db = App::getInstance()->db();
$q = trim((string) $request->get('q', ''));
$type = (string) $request->get('type', '');
$where = ['is_archived = 0', 'is_active = 1', 'is_header = 0'];
$params = [];
if ($q !== '') {
$where[] = '(account_code LIKE ? OR name_ar LIKE ? OR name_en LIKE ?)';
$like = '%' . $q . '%';
$params[] = $like;
$params[] = $like;
$params[] = $like;
}
if ($type !== '') {
$where[] = 'account_type = ?';
$params[] = $type;
}
return $this->json([
'accounts' => $db->select(
"SELECT id, account_code, name_ar, account_type
FROM chart_of_accounts
WHERE " . implode(' AND ', $where) . "
ORDER BY account_code LIMIT 60",
$params
),
]);
}
// ────────────────────────────────────────────────────────────
/**
* Read the repeating line inputs off the request and normalise them.
*
* @return array{lines:array<int,array>, errors:array<int,string>}
*/
private function parseLines(Request $request, bool $forSimulation = false): array
{
$raw = $request->input('lines', []);
if (is_string($raw)) {
$decoded = json_decode($raw, true);
$raw = is_array($decoded) ? $decoded : [];
}
if (!is_array($raw)) {
$raw = [];
}
$lines = [];
$errors = [];
foreach (array_values($raw) as $i => $row) {
if (!is_array($row)) {
continue;
}
$accountId = isset($row['account_id']) ? (int) $row['account_id'] : 0;
if ($accountId <= 0) {
continue;
}
$method = (string) ($row['allocation_method'] ?? 'remainder');
if (!\in_array($method, ['fixed', 'percentage', 'remainder'], true)) {
$method = 'remainder';
}
$lineType = (string) ($row['line_type'] ?? 'revenue');
if (!\in_array($lineType, ['revenue', 'deferred_revenue', 'passthrough', 'contra_revenue', 'receivable_offset'], true)) {
$lineType = 'revenue';
}
$base = (string) ($row['percentage_base'] ?? 'net_after_fixed');
if (!\in_array($base, ['net_after_fixed', 'net_total', 'gross_total'], true)) {
$base = 'net_after_fixed';
}
$recognition = (string) ($row['recognition_method'] ?? 'immediate');
if (!\in_array($recognition, ['immediate', 'straight_line'], true)) {
$recognition = 'immediate';
}
if ($method === 'fixed' && (string) ($row['fixed_amount'] ?? '') === '') {
$errors[] = 'البند رقم ' . ($i + 1) . ': مبلغ ثابت مطلوب';
}
if ($method === 'percentage' && (string) ($row['percentage'] ?? '') === '') {
$errors[] = 'البند رقم ' . ($i + 1) . ': نسبة مطلوبة';
}
if ($lineType === 'deferred_revenue' && $recognition === 'straight_line' && empty($row['recognized_account_id'])) {
$errors[] = 'البند رقم ' . ($i + 1) . ': حدد حساب الإيراد الذي يُرحَّل إليه المؤجل';
}
$lines[] = [
'id' => isset($row['id']) && $row['id'] !== '' ? (int) $row['id'] : null,
'sort_order' => $i + 1,
'line_type' => $lineType,
'allocation_method' => $method,
'fixed_amount' => ($row['fixed_amount'] ?? '') !== '' ? (string) $row['fixed_amount'] : null,
'percentage' => ($row['percentage'] ?? '') !== '' ? (string) $row['percentage'] : null,
'percentage_base' => $base,
'account_id' => $accountId,
'cost_center_id' => !empty($row['cost_center_id']) ? (int) $row['cost_center_id'] : null,
'recognition_method' => $recognition,
'recognition_months' => !empty($row['recognition_months']) ? (int) $row['recognition_months'] : null,
'recognized_account_id' => !empty($row['recognized_account_id']) ? (int) $row['recognized_account_id'] : null,
'description_ar' => ($row['description_ar'] ?? '') !== '' ? (string) $row['description_ar'] : null,
'max_amount' => ($row['max_amount'] ?? '') !== '' ? (string) $row['max_amount'] : null,
'min_amount' => null,
'is_active' => 1,
];
}
if (empty($lines) && !$forSimulation) {
$errors[] = 'أضف بندًا واحدًا على الأقل';
}
$remainders = array_filter($lines, static fn(array $l): bool => $l['allocation_method'] === 'remainder');
if (!$forSimulation) {
if (count($remainders) === 0) {
$errors[] = 'يجب وجود بند واحد من نوع «الباقي»';
} elseif (count($remainders) > 1) {
$errors[] = 'لا يمكن وجود أكثر من بند «الباقي»';
}
}
return ['lines' => $lines, 'errors' => $errors];
}
private function summary(): array
{
$db = App::getInstance()->db();
$total = $db->selectOne("SELECT COUNT(*) AS n FROM revenue_streams WHERE is_active = 1");
$mapped = $db->selectOne(
"SELECT COUNT(DISTINCT r.stream_id) AS n FROM revenue_posting_rules r
WHERE r.status = 'active' AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())"
);
$split = $db->selectOne(
"SELECT COUNT(*) AS n FROM (
SELECT r.stream_id FROM revenue_posting_rules r
JOIN revenue_posting_rule_lines l ON l.rule_id = r.id AND l.is_active = 1
WHERE r.status = 'active'
GROUP BY r.stream_id HAVING COUNT(*) > 1
) x"
);
$catchAll = $db->selectOne(
"SELECT COUNT(DISTINCT r.stream_id) AS n
FROM revenue_posting_rules r
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
WHERE r.status = 'active' AND coa.account_code = '410515'"
);
$deferred = $db->selectOne(
"SELECT COALESCE(SUM(amount), 0) AS total FROM revenue_recognition_schedules WHERE status = 'pending'"
);
return [
'total' => (int) ($total['n'] ?? 0),
'mapped' => (int) ($mapped['n'] ?? 0),
'unmapped' => max(0, ((int) ($total['n'] ?? 0)) - ((int) ($mapped['n'] ?? 0))),
'split' => (int) ($split['n'] ?? 0),
'catch_all' => (int) ($catchAll['n'] ?? 0),
'deferred' => (string) ($deferred['total'] ?? '0.00'),
];
}
public static function categories(): array
{
return [
'membership' => 'العضويات',
'subscription' => 'الاشتراكات',
'activity' => 'الأنشطة',
'academy' => 'الأكاديميات',
'facility' => 'المرافق',
'transfer' => 'التحويلات والحالات',
'penalty' => 'الغرامات',
'retail' => 'المبيعات',
'rental' => 'الإيجارات',
'other' => 'أخرى',
];
}
}
......@@ -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());
}
}
}
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
/**
* Pure allocation math for the revenue posting engine. No database, no side effects —
* so it can be unit-reasoned about and used for live UI simulation.
*
* Order of operations is fixed and deterministic (this is what makes the result
* defensible to an auditor):
*
* 1. Tax extraction — split the collected gross into net + tax.
* 2. Fixed lines — flat amounts taken off the net, in sort_order.
* 3. Percentage lines — computed on the configured base.
* 4. Remainder line — absorbs everything left, including rounding residue.
*
* All money is handled as bcmath strings at 2dp. Never floats.
*/
final class RevenueAllocator
{
public const SCALE = 2;
/**
* @param string $gross Amount actually collected (or invoiced), 2dp string.
* @param array $lines Rule lines, each: [id, line_type, allocation_method,
* fixed_amount, percentage, percentage_base, account_id,
* cost_center_id, branch_id, min_amount, max_amount,
* description_ar, recognition_method, recognition_months,
* recognized_account_id]
* @param array|null $taxProfile [rate, is_price_inclusive, treatment, output_tax_account_id]
*
* @return array{
* gross:string, net:string, tax:string, tax_account_id:?int,
* allocations:array<int,array>, unallocated:string, errors:array<int,string>,
* warnings:array<int,string>
* }
*/
public static function allocate(string $gross, array $lines, ?array $taxProfile = null): array
{
$errors = [];
$warnings = [];
$gross = self::money($gross);
if (bccomp($gross, '0.00', self::SCALE) <= 0) {
return self::emptyResult($gross, 'المبلغ يجب أن يكون أكبر من صفر');
}
// ── 1. Tax layer ────────────────────────────────────────────────
// Inclusive: the collected amount already contains the tax, so extract it.
// net = gross / (1 + rate), tax = gross - net
// Exclusive: the collected amount is the net; tax was charged on top and is
// assumed to be part of what was collected only if the caller says so.
// For a collection posting we can only distribute what we received, so an
// exclusive profile still extracts from the received total but flags it.
$tax = '0.00';
$net = $gross;
$taxAccountId = null;
if ($taxProfile !== null && self::taxIsChargeable($taxProfile)) {
$rate = self::rate((string) ($taxProfile['rate'] ?? '0'));
$taxAccountId = isset($taxProfile['output_tax_account_id']) && $taxProfile['output_tax_account_id']
? (int) $taxProfile['output_tax_account_id']
: null;
if (bccomp($rate, '0.000000', 6) > 0) {
if ((int) ($taxProfile['is_price_inclusive'] ?? 1) === 1) {
// net = gross / (1 + rate)
$divisor = bcadd('1', $rate, 8);
$net = self::money(bcdiv($gross, $divisor, 8));
$tax = bcsub($gross, $net, self::SCALE);
} else {
// Price is tax-exclusive: the collected amount IS the net and the
// tax sits on top. The entry must then be grossed up by the caller.
$net = $gross;
$tax = self::money(bcmul($gross, $rate, 8));
$warnings[] = 'الضريبة محسوبة فوق المبلغ (غير شاملة) — إجمالي القيد سيزيد بمقدار الضريبة';
}
if ($taxAccountId === null) {
$errors[] = 'الملف الضريبي لا يحتوي على حساب ضريبة مخرجات';
}
}
}
// ── 2 & 3. Distribute the net across the rule lines ─────────────
$active = array_values(array_filter($lines, static fn(array $l): bool => (int) ($l['is_active'] ?? 1) === 1));
usort($active, static fn(array $a, array $b): int => ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0)));
$remainders = array_values(array_filter($active, static fn(array $l): bool => ($l['allocation_method'] ?? '') === 'remainder'));
if (count($remainders) === 0) {
$errors[] = 'يجب وجود بند واحد من نوع "الباقي" لاستيعاب المتبقي وفروق التقريب';
} elseif (count($remainders) > 1) {
$errors[] = 'لا يمكن وجود أكثر من بند واحد من نوع "الباقي"';
}
$allocations = [];
$pool = $net; // what is still unassigned
$fixedTotal = '0.00';
// 2. Fixed lines
foreach ($active as $line) {
if (($line['allocation_method'] ?? '') !== 'fixed') {
continue;
}
$amount = self::money((string) ($line['fixed_amount'] ?? '0'));
if (!self::passesThreshold($line, $net)) {
continue;
}
$amount = self::applyCap($line, $amount);
// Never allocate more than what is left in the pool.
if (bccomp($amount, $pool, self::SCALE) > 0) {
$amount = bccomp($pool, '0.00', self::SCALE) > 0 ? $pool : '0.00';
$warnings[] = 'البند "' . ($line['description_ar'] ?? '') . '" تم تخفيضه لعدم كفاية المبلغ';
}
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$pool = bcsub($pool, $amount, self::SCALE);
$fixedTotal = bcadd($fixedTotal, $amount, self::SCALE);
$allocations[] = self::allocation($line, $amount);
}
$netAfterFixed = bcsub($net, $fixedTotal, self::SCALE);
// 3. Percentage lines
foreach ($active as $line) {
if (($line['allocation_method'] ?? '') !== 'percentage') {
continue;
}
if (!self::passesThreshold($line, $net)) {
continue;
}
$base = match ($line['percentage_base'] ?? 'net_after_fixed') {
'gross_total' => $gross,
'net_total' => $net,
default => $netAfterFixed,
};
$pct = self::rate((string) ($line['percentage'] ?? '0'));
$amount = self::money(bcmul($base, $pct, 8));
$amount = self::applyCap($line, $amount);
if (bccomp($amount, $pool, self::SCALE) > 0) {
$amount = bccomp($pool, '0.00', self::SCALE) > 0 ? $pool : '0.00';
$warnings[] = 'البند "' . ($line['description_ar'] ?? '') . '" تم تخفيضه لعدم كفاية المبلغ';
}
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$pool = bcsub($pool, $amount, self::SCALE);
$allocations[] = self::allocation($line, $amount);
}
// 4. Remainder — absorbs the rest plus any rounding residue.
if (count($remainders) === 1) {
$line = $remainders[0];
$amount = $pool;
if (bccomp($amount, '0.00', self::SCALE) < 0) {
$errors[] = 'التوزيع تجاوز المبلغ المتاح — راجع النسب والمبالغ الثابتة';
$amount = '0.00';
}
if (bccomp($amount, '0.00', self::SCALE) > 0) {
$allocations[] = self::allocation($line, $amount);
$pool = '0.00';
} else {
$warnings[] = 'لم يتبقَّ مبلغ لبند "الباقي"';
}
}
// ── Integrity check ─────────────────────────────────────────────
$allocated = '0.00';
foreach ($allocations as $a) {
$allocated = bcadd($allocated, $a['amount'], self::SCALE);
}
if (bccomp($allocated, $net, self::SCALE) !== 0) {
$diff = bcsub($net, $allocated, self::SCALE);
$errors[] = 'مجموع التوزيع (' . $allocated . ') لا يساوي الصافي (' . $net . ') — الفرق ' . $diff;
}
return [
'gross' => $gross,
'net' => $net,
'tax' => $tax,
'tax_account_id' => $taxAccountId,
'allocations' => $allocations,
'unallocated' => $pool,
'errors' => $errors,
'warnings' => $warnings,
];
}
/**
* Split an amount evenly across N periods, pushing rounding residue into the
* final period so the schedule always sums back to the original amount.
*
* @return array<int,string>
*/
public static function straightLine(string $amount, int $months): array
{
$amount = self::money($amount);
if ($months < 1) {
return [$amount];
}
$per = self::money(bcdiv($amount, (string) $months, 8));
$parts = array_fill(0, $months, $per);
$sum = '0.00';
foreach ($parts as $p) {
$sum = bcadd($sum, $p, self::SCALE);
}
$residue = bcsub($amount, $sum, self::SCALE);
$parts[$months - 1] = bcadd($parts[$months - 1], $residue, self::SCALE);
return $parts;
}
// ────────────────────────────────────────────────────────────────────
private static function allocation(array $line, string $amount): array
{
return [
'rule_line_id' => isset($line['id']) ? (int) $line['id'] : null,
'line_type' => $line['line_type'] ?? 'revenue',
'account_id' => (int) $line['account_id'],
'amount' => $amount,
'cost_center_id' => $line['cost_center_id'] ?? null,
'branch_id' => $line['branch_id'] ?? null,
'description_ar' => $line['description_ar'] ?? null,
'allocation_method' => $line['allocation_method'] ?? 'remainder',
'percentage' => $line['percentage'] ?? null,
'fixed_amount' => $line['fixed_amount'] ?? null,
'recognition_method' => $line['recognition_method'] ?? 'immediate',
'recognition_months' => isset($line['recognition_months']) && $line['recognition_months'] !== null
? (int) $line['recognition_months']
: null,
'recognized_account_id' => isset($line['recognized_account_id']) && $line['recognized_account_id']
? (int) $line['recognized_account_id']
: null,
];
}
private static function passesThreshold(array $line, string $net): bool
{
if (isset($line['min_amount']) && $line['min_amount'] !== null && $line['min_amount'] !== '') {
if (bccomp($net, self::money((string) $line['min_amount']), self::SCALE) < 0) {
return false;
}
}
return true;
}
private static function applyCap(array $line, string $amount): string
{
if (isset($line['max_amount']) && $line['max_amount'] !== null && $line['max_amount'] !== '') {
$cap = self::money((string) $line['max_amount']);
if (bccomp($amount, $cap, self::SCALE) > 0) {
return $cap;
}
}
return $amount;
}
private static function taxIsChargeable(array $profile): bool
{
$treatment = $profile['treatment'] ?? 'standard';
return \in_array($treatment, ['standard', 'table'], true);
}
/** Convert a percentage (14.0000) into a multiplier string (0.14). */
private static function rate(string $percentage): string
{
return bcdiv($percentage, '100', 8);
}
private static function money(string $value): string
{
// bcadd with scale truncates rather than rounds; round explicitly first.
return number_format((float) $value, self::SCALE, '.', '');
}
private static function emptyResult(string $gross, string $error): array
{
return [
'gross' => $gross,
'net' => '0.00',
'tax' => '0.00',
'tax_account_id' => null,
'allocations' => [],
'unallocated' => '0.00',
'errors' => [$error],
'warnings' => [],
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\JournalService;
/**
* Account determination for revenue.
*
* Resolves the active posting rule for a revenue stream, allocates the collected
* amount across its lines, and produces a balanced journal entry.
*
* Falls back to the legacy hardcoded AccountCodes mapping when no rule is configured,
* so turning this on changes nothing until finance actually configures a stream.
*/
final class RevenuePostingEngine
{
private const SCALE = 2;
/**
* Build and post the journal entry for a collected amount.
*
* @param string $streamCode e.g. 'payment:membership_fee'
* @param array $ctx [
* amount, entry_date, payment_method, treasury_id, branch_id, member_id,
* reference_type, reference_id, reference_number, source_module,
* description_ar, description_en, period_months
* ]
* @return array{success:bool, journal_entry_id?:int, error?:string, used_rule?:bool}
*/
public static function post(string $streamCode, array $ctx): array
{
$plan = self::plan($streamCode, $ctx);
if (!$plan['resolved']) {
return ['success' => false, 'error' => $plan['error'] ?? 'تعذر تحديد قاعدة التوزيع', 'used_rule' => false];
}
if (!empty($plan['errors'])) {
self::log($plan, $ctx, null, 'failed', implode(' | ', $plan['errors']));
return ['success' => false, 'error' => implode(' | ', $plan['errors']), 'used_rule' => true];
}
$result = JournalService::createEntry($plan['header'], $plan['lines'], true);
if (!$result['success']) {
self::log($plan, $ctx, null, 'failed', $result['error'] ?? '');
return ['success' => false, 'error' => $result['error'] ?? 'فشل إنشاء القيد', 'used_rule' => true];
}
$entryId = (int) $result['journal_entry_id'];
self::log($plan, $ctx, $entryId, 'posted', null);
// Lay down the deferred-revenue amortisation schedule, if any.
if (!empty($plan['deferrals'])) {
RevenueRecognitionService::schedule($plan['deferrals'], $ctx, $entryId, $plan['stream']['id'] ?? null);
}
return ['success' => true, 'journal_entry_id' => $entryId, 'used_rule' => true];
}
/**
* Produce the full posting plan without writing anything.
* Used by both post() and the UI simulator, so what you preview is what you get.
*
* @return array{resolved:bool, stream:?array, rule:?array, allocation:?array,
* header:array, lines:array, deferrals:array, errors:array, warnings:array}
*/
public static function plan(string $streamCode, array $ctx): array
{
$db = App::getInstance()->db();
$blank = [
'resolved' => false, 'stream' => null, 'rule' => null, 'allocation' => null,
'header' => [], 'lines' => [], 'deferrals' => [], 'errors' => [], 'warnings' => [],
];
$amount = self::money((string) ($ctx['amount'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
return $blank + ['error' => 'المبلغ صفر أو أقل'];
}
$stream = $db->selectOne(
"SELECT * FROM revenue_streams WHERE stream_code = ? AND is_active = 1",
[$streamCode]
);
if (!$stream) {
return $blank + ['error' => 'لا يوجد مصدر إيراد بالكود ' . $streamCode];
}
$entryDate = $ctx['entry_date'] ?? date('Y-m-d');
$rule = self::resolveRule((int) $stream['id'], $entryDate, $ctx);
if (!$rule) {
return $blank + ['stream' => $stream, 'error' => 'لا توجد قاعدة توزيع مفعّلة لهذا المصدر'];
}
$lines = $db->select(
"SELECT * FROM revenue_posting_rule_lines WHERE rule_id = ? AND is_active = 1 ORDER BY sort_order ASC, id ASC",
[(int) $rule['id']]
);
if (empty($lines)) {
return $blank + ['stream' => $stream, 'rule' => $rule, 'error' => 'قاعدة التوزيع بدون بنود'];
}
$taxProfile = null;
if (!empty($rule['tax_profile_id'])) {
$taxProfile = $db->selectOne(
"SELECT * FROM revenue_tax_profiles WHERE id = ? AND is_active = 1",
[(int) $rule['tax_profile_id']]
);
}
$alloc = RevenueAllocator::allocate($amount, $lines, $taxProfile);
$errors = $alloc['errors'];
$warnings = $alloc['warnings'];
// ── Debit side (where the money landed) ─────────────────────────
$debitAccountId = self::resolveDebitAccount($rule, $ctx, $errors);
// Tax-exclusive profiles gross the entry up: we credit tax on top of the net.
$taxExclusive = $taxProfile !== null && (int) ($taxProfile['is_price_inclusive'] ?? 1) === 0;
$debitTotal = $taxExclusive ? bcadd($alloc['net'], $alloc['tax'], self::SCALE) : $alloc['gross'];
$description = $ctx['description_ar'] ?? ('تحصيل ' . ($stream['name_ar'] ?? $streamCode));
$jLines = [];
$memberId = isset($ctx['member_id']) && (int) $ctx['member_id'] > 0 ? (int) $ctx['member_id'] : null;
if ($debitAccountId !== null && bccomp($debitTotal, '0.00', self::SCALE) > 0) {
$jLines[] = [
'account_id' => $debitAccountId,
'debit' => $debitTotal,
'credit' => '0.00',
'description_ar' => $description,
'member_id' => $memberId,
'cost_center_id' => $rule['cost_center_id'] ?? null,
'branch_id' => $ctx['branch_id'] ?? null,
];
}
// ── Credit side: tax first, then the allocation ─────────────────
if (bccomp($alloc['tax'], '0.00', self::SCALE) > 0) {
if ($alloc['tax_account_id'] === null) {
$errors[] = 'لم يتم تحديد حساب ضريبة المخرجات';
} else {
self::assertPostable($alloc['tax_account_id'], 'حساب الضريبة', $errors);
$jLines[] = [
'account_id' => $alloc['tax_account_id'],
'debit' => '0.00',
'credit' => $alloc['tax'],
'description_ar' => 'ضريبة قيمة مضافة — ' . $description,
'branch_id' => $ctx['branch_id'] ?? null,
];
}
}
$deferrals = [];
foreach ($alloc['allocations'] as $a) {
if (bccomp($a['amount'], '0.00', self::SCALE) <= 0) {
continue;
}
self::assertPostable($a['account_id'], 'حساب البند', $errors);
$lineDesc = $a['description_ar'] ?: $description;
// contra_revenue reduces revenue → it is a DEBIT, not a credit.
$isContra = ($a['line_type'] === 'contra_revenue');
$jLines[] = [
'account_id' => $a['account_id'],
'debit' => $isContra ? $a['amount'] : '0.00',
'credit' => $isContra ? '0.00' : $a['amount'],
'description_ar' => $lineDesc,
'member_id' => \in_array($a['line_type'], ['receivable_offset'], true) ? $memberId : null,
'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null,
'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null,
];
if ($isContra) {
$errors[] = 'بند "خصم من الإيراد" يتطلب مصدر خصم صريح — غير مدعوم في تحصيل مباشر';
}
// Deferred revenue → build the amortisation schedule.
if ($a['line_type'] === 'deferred_revenue' && $a['recognition_method'] === 'straight_line') {
$months = $a['recognition_months'] ?? (int) ($ctx['period_months'] ?? 12);
if ($months < 1) {
$months = 12;
}
if (!$a['recognized_account_id']) {
$errors[] = 'بند الإيراد المؤجل بدون حساب إيراد يُرحَّل إليه';
} else {
self::assertPostable($a['recognized_account_id'], 'حساب الإيراد المستحق', $errors);
$deferrals[] = [
'rule_line_id' => $a['rule_line_id'],
'deferred_account_id' => $a['account_id'],
'revenue_account_id' => $a['recognized_account_id'],
'amount' => $a['amount'],
'months' => $months,
'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null,
'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null,
'description_ar' => $lineDesc,
];
}
}
}
if ($debitAccountId !== null) {
self::assertPostable($debitAccountId, 'الحساب المدين', $errors);
}
// Final balance check before we hand it to JournalService.
$dr = '0.00';
$cr = '0.00';
foreach ($jLines as $l) {
$dr = bcadd($dr, (string) $l['debit'], self::SCALE);
$cr = bcadd($cr, (string) $l['credit'], self::SCALE);
}
if (bccomp($dr, $cr, self::SCALE) !== 0) {
$errors[] = 'القيد غير متوازن: مدين ' . $dr . ' مقابل دائن ' . $cr;
}
if (count($jLines) < 2) {
$errors[] = 'القيد يحتاج سطرين على الأقل';
}
return [
'resolved' => true,
'stream' => $stream,
'rule' => $rule,
'allocation' => $alloc,
'header' => [
'entry_date' => $entryDate,
'description_ar' => $description,
'description_en' => $ctx['description_en'] ?? null,
'reference_type' => $ctx['reference_type'] ?? null,
'reference_id' => $ctx['reference_id'] ?? null,
'reference_number' => $ctx['reference_number'] ?? null,
'source_module' => $ctx['source_module'] ?? ($stream['source_module'] ?? null),
'branch_id' => $ctx['branch_id'] ?? null,
'cost_center_id' => $rule['cost_center_id'] ?? null,
'is_auto_generated' => 1,
],
'lines' => $jLines,
'deferrals' => $deferrals,
'errors' => $errors,
'warnings' => $warnings,
'totals' => ['debit' => $dr, 'credit' => $cr],
];
}
/**
* Pick the active rule for a stream on a date.
* Most specific scope wins: branch+method > branch > method > global.
*/
public static function resolveRule(int $streamId, string $onDate, array $ctx = []): ?array
{
$db = App::getInstance()->db();
$branchId = isset($ctx['branch_id']) && $ctx['branch_id'] ? (int) $ctx['branch_id'] : null;
$method = $ctx['payment_method'] ?? null;
$candidates = $db->select(
"SELECT * FROM revenue_posting_rules
WHERE stream_id = ?
AND status = 'active'
AND effective_from <= ?
AND (effective_to IS NULL OR effective_to >= ?)
AND (branch_id IS NULL OR branch_id = ?)
AND (payment_method IS NULL OR payment_method = ?)
ORDER BY effective_from DESC, version DESC",
[$streamId, $onDate, $onDate, $branchId, $method]
);
if (empty($candidates)) {
return null;
}
// Score specificity so the narrowest match wins deterministically.
usort($candidates, static function (array $a, array $b): int {
$score = static fn(array $r): int =>
($r['branch_id'] !== null ? 2 : 0) + ($r['payment_method'] !== null ? 1 : 0);
$diff = $score($b) <=> $score($a);
if ($diff !== 0) {
return $diff;
}
$diff = strcmp((string) $b['effective_from'], (string) $a['effective_from']);
if ($diff !== 0) {
return $diff;
}
return ((int) $b['version']) <=> ((int) $a['version']);
});
return $candidates[0];
}
/** Does this stream have an active rule right now? Used by the UI status column. */
public static function isConfigured(int $streamId): bool
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) AS n FROM revenue_posting_rules
WHERE stream_id = ? AND status = 'active'
AND effective_from <= CURDATE()
AND (effective_to IS NULL OR effective_to >= CURDATE())",
[$streamId]
);
return ((int) ($row['n'] ?? 0)) > 0;
}
// ────────────────────────────────────────────────────────────────────
private static function resolveDebitAccount(array $rule, array $ctx, array &$errors): ?int
{
$db = App::getInstance()->db();
$source = $rule['debit_source'] ?? 'auto_treasury';
if ($source === 'fixed_account') {
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'])) {
$errors[] = 'قاعدة التوزيع تستخدم حساب المدينين ولكنه غير محدد';
return null;
}
return (int) $rule['debit_account_id'];
}
// auto_treasury — derive from the payment method / treasury like the legacy path.
$method = $ctx['payment_method'] ?? 'cash';
$treasuryId = isset($ctx['treasury_id']) && $ctx['treasury_id'] ? (int) $ctx['treasury_id'] : null;
$code = AccountCodes::debitAccountForTreasury($method, $treasuryId);
$account = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$code]
);
if (!$account) {
$errors[] = 'الحساب المدين ' . $code . ' غير موجود في دليل الحسابات';
return null;
}
return (int) $account['id'];
}
/**
* A header or inactive account is rejected by JournalService at post time, which
* shows up as a silent failure. Catch it here so the message is actionable.
*/
private static function assertPostable(int $accountId, string $label, array &$errors): void
{
static $cache = [];
if (!isset($cache[$accountId])) {
$db = App::getInstance()->db();
$cache[$accountId] = $db->selectOne(
"SELECT id, account_code, name_ar, is_header, is_active FROM chart_of_accounts WHERE id = ?",
[$accountId]
);
}
$acc = $cache[$accountId];
if (!$acc) {
$errors[] = $label . ' غير موجود (رقم ' . $accountId . ')';
return;
}
if ((int) $acc['is_header'] === 1) {
$errors[] = $label . ' «' . $acc['account_code'] . ' ' . $acc['name_ar'] . '» حساب رئيسي — لا يقبل الترحيل. اختر حسابًا فرعيًا.';
}
if ((int) $acc['is_active'] === 0) {
$errors[] = $label . ' «' . $acc['account_code'] . '» غير نشط';
}
}
private static function log(array $plan, array $ctx, ?int $entryId, string $outcome, ?string $message): void
{
try {
$db = App::getInstance()->db();
$alloc = $plan['allocation'] ?? [];
$db->insert('revenue_posting_log', [
'stream_id' => isset($plan['stream']['id']) ? (int) $plan['stream']['id'] : null,
'rule_id' => isset($plan['rule']['id']) ? (int) $plan['rule']['id'] : null,
'rule_version' => isset($plan['rule']['version']) ? (int) $plan['rule']['version'] : null,
'journal_entry_id' => $entryId,
'source_module' => $ctx['source_module'] ?? null,
'source_reference_type' => $ctx['reference_type'] ?? null,
'source_reference_id' => $ctx['reference_id'] ?? null,
'gross_amount' => $alloc['gross'] ?? '0.00',
'tax_amount' => $alloc['tax'] ?? '0.00',
'net_amount' => $alloc['net'] ?? '0.00',
'allocation_snapshot' => json_encode([
'allocations' => $alloc['allocations'] ?? [],
'lines' => $plan['lines'] ?? [],
], JSON_UNESCAPED_UNICODE),
'outcome' => $outcome,
'message' => $message !== null ? mb_substr($message, 0, 500) : null,
]);
} catch (\Throwable $e) {
Logger::error('RevenuePostingEngine log failed: ' . $e->getMessage());
}
}
private static function money(string $value): string
{
return number_format((float) $value, self::SCALE, '.', '');
}
}
<?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
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
/**
* The catalogue of every chargeable thing in the ERP.
*
* definitions() is the declared list. sync() writes it into revenue_streams and
* also picks up any payment_type that appears in live data but was never declared —
* which is how `foreign_membership_fee`, `sports_membership_fee`, `early_settlement`
* and friends were quietly falling through to the miscellaneous catch-all.
*/
final class RevenueStreamRegistry
{
/**
* Declared streams. Keyed by stream_code.
*
* legacy_account is the account the old hardcoded AccountCodes mapping used —
* kept so the seed can reproduce current behaviour exactly before anyone
* changes anything.
*/
public static function definitions(): array
{
return [
// ── Membership ───────────────────────────────────────────────
'payment:form_fee' => [
'name_ar' => 'رسوم استمارة عضوية', 'name_en' => 'Membership Form Fee',
'module' => 'payments', 'key' => 'form_fee', 'category' => 'membership',
'legacy_account' => '410103',
],
'payment:membership_fee' => [
'name_ar' => 'قيمة العضوية', 'name_en' => 'Membership Value',
'module' => 'payments', 'key' => 'membership_fee', 'category' => 'membership',
'legacy_account' => '410101',
],
'payment:addition_fee' => [
'name_ar' => 'رسوم إضافة تابع', 'name_en' => 'Dependent Addition Fee',
'module' => 'payments', 'key' => 'addition_fee', 'category' => 'membership',
'legacy_account' => '410102',
],
'payment:membership_renewal' => [
'name_ar' => 'تجديد عضوية', 'name_en' => 'Membership Renewal',
'module' => 'payments', 'key' => 'membership_renewal', 'category' => 'membership',
'legacy_account' => '410104',
],
'payment:foreign_membership_fee' => [
'name_ar' => 'عضوية أجانب', 'name_en' => 'Foreign Membership',
'module' => 'payments', 'key' => 'foreign_membership_fee', 'category' => 'membership',
'legacy_account' => '410515',
],
'payment:seasonal_fee' => [
'name_ar' => 'عضوية موسمية', 'name_en' => 'Seasonal Membership',
'module' => 'payments', 'key' => 'seasonal_fee', 'category' => 'membership',
'legacy_account' => '410501',
],
'payment:carnet_replacement' => [
'name_ar' => 'بدل فاقد كارنيه', 'name_en' => 'Carnet Replacement',
'module' => 'payments', 'key' => 'carnet_replacement', 'category' => 'membership',
'legacy_account' => '410529',
],
// ── Transfers / cases ────────────────────────────────────────
'payment:separation_fee' => [
'name_ar' => 'رسوم فصل', 'name_en' => 'Separation Fee',
'module' => 'payments', 'key' => 'separation_fee', 'category' => 'transfer',
'legacy_account' => '410515',
],
'payment:divorce_fee' => [
'name_ar' => 'رسوم طلاق', 'name_en' => 'Divorce Fee',
'module' => 'payments', 'key' => 'divorce_fee', 'category' => 'transfer',
'legacy_account' => '410302',
],
'payment:death_fee' => [
'name_ar' => 'رسوم نقل عضوية وفاة', 'name_en' => 'Death Transfer Fee',
'module' => 'payments', 'key' => 'death_fee', 'category' => 'transfer',
'legacy_account' => '410515',
],
'payment:waiver_fee' => [
'name_ar' => 'رسوم تنازل', 'name_en' => 'Waiver Fee',
'module' => 'payments', 'key' => 'waiver_fee', 'category' => 'transfer',
'legacy_account' => '410515',
],
'payment:sports_conversion' => [
'name_ar' => 'رسوم تحويل رياضي', 'name_en' => 'Sports Conversion Fee',
'module' => 'payments', 'key' => 'sports_conversion', 'category' => 'transfer',
'legacy_account' => '410515',
],
// ── Subscriptions ────────────────────────────────────────────
'payment:annual_subscription' => [
'name_ar' => 'اشتراك سنوي', 'name_en' => 'Annual Subscription',
'module' => 'payments', 'key' => 'annual_subscription', 'category' => 'subscription',
'legacy_account' => '410201',
],
'payment:development_fee' => [
'name_ar' => 'رسوم تنمية', 'name_en' => 'Development Fee',
'module' => 'payments', 'key' => 'development_fee', 'category' => 'subscription',
'legacy_account' => '410202',
],
'payment:sports_subscription' => [
'name_ar' => 'اشتراك نشاط رياضي', 'name_en' => 'Sports Activity Subscription',
'module' => 'payments', 'key' => 'sports_subscription', 'category' => 'activity',
'legacy_account' => '410515',
],
'payment:activity_subscription' => [
'name_ar' => 'اشتراك نشاط', 'name_en' => 'Activity Subscription',
'module' => 'payments', 'key' => 'activity_subscription', 'category' => 'activity',
'legacy_account' => '410516',
],
// ── Instalments ──────────────────────────────────────────────
'payment:down_payment' => [
'name_ar' => 'مقدم تقسيط', 'name_en' => 'Down Payment',
'module' => 'payments', 'key' => 'down_payment', 'category' => 'membership',
'legacy_account' => '410503',
],
'payment:installment' => [
'name_ar' => 'قسط', 'name_en' => 'Instalment',
'module' => 'payments', 'key' => 'installment', 'category' => 'membership',
'legacy_account' => '410510',
],
'payment:early_settlement' => [
'name_ar' => 'سداد معجل', 'name_en' => 'Early Settlement',
'module' => 'payments', 'key' => 'early_settlement', 'category' => 'membership',
'legacy_account' => '410515',
],
// ── Sports academy ───────────────────────────────────────────
'payment:sports_registration' => [
'name_ar' => 'رسوم تسجيل رياضي', 'name_en' => 'Sports Registration',
'module' => 'payments', 'key' => 'sports_registration', 'category' => 'academy',
'legacy_account' => '410516',
],
'payment:sa_form_fee' => [
'name_ar' => 'استمارة نشاط رياضي', 'name_en' => 'Sports Activity Form',
'module' => 'payments', 'key' => 'sa_form_fee', 'category' => 'academy',
'legacy_account' => '410516',
],
'payment:sa_registration_fee' => [
'name_ar' => 'رسوم قيد أكاديمية', 'name_en' => 'Academy Registration Fee',
'module' => 'payments', 'key' => 'sa_registration_fee', 'category' => 'academy',
'legacy_account' => '410515',
],
'payment:sports_membership_fee' => [
'name_ar' => 'عضوية رياضية', 'name_en' => 'Sports Membership',
'module' => 'payments', 'key' => 'sports_membership_fee', 'category' => 'academy',
'legacy_account' => '410515',
],
// ── Facilities & bookings ────────────────────────────────────
'payment:hourly_booking' => [
'name_ar' => 'حجز ملاعب بالساعة', 'name_en' => 'Hourly Court Booking',
'module' => 'payments', 'key' => 'hourly_booking', 'category' => 'facility',
'legacy_account' => '410523',
],
'facility:entry' => [
'name_ar' => 'تذاكر دخول المرافق', 'name_en' => 'Facility Entry Tickets',
'module' => 'facilities', 'key' => null, 'category' => 'facility',
'event' => 'facility.entry_recorded', 'legacy_account' => '410518',
],
'carnet:guest_entry' => [
'name_ar' => 'دخول ضيوف بالكارنيه', 'name_en' => 'Carnet Guest Entry',
'module' => 'carnets', 'key' => null, 'category' => 'facility',
'event' => 'carnet.guest_entry_recorded', 'legacy_account' => '410518',
],
// ── Penalties ────────────────────────────────────────────────
'payment:fine' => [
'name_ar' => 'غرامة مخالفة', 'name_en' => 'Violation Fine',
'module' => 'payments', 'key' => 'fine', 'category' => 'penalty',
'legacy_account' => '410512',
],
'fine:imposed' => [
'name_ar' => 'استحقاق غرامة', 'name_en' => 'Fine Accrual',
'module' => 'fines', 'key' => null, 'category' => 'penalty',
'event' => 'fine.imposed', 'legacy_account' => '410512',
],
// ── Retail / rental / events ─────────────────────────────────
'payment:inventory_sale' => [
'name_ar' => 'مبيعات', 'name_en' => 'Retail Sales',
'module' => 'payments', 'key' => 'inventory_sale', 'category' => 'retail',
'legacy_account' => '410515',
],
'rental:invoice' => [
'name_ar' => 'فاتورة إيجار', 'name_en' => 'Rental Invoice',
'module' => 'rentals', 'key' => null, 'category' => 'rental',
'event' => 'rental.invoice_paid', 'legacy_account' => '410521',
],
'tournament:fee' => [
'name_ar' => 'رسوم بطولة', 'name_en' => 'Tournament Fee',
'module' => 'tournaments', 'key' => null, 'category' => 'activity',
'event' => 'tournament.fee_collected', 'legacy_account' => '410517',
],
// ── Catch-all ────────────────────────────────────────────────
'payment:other' => [
'name_ar' => 'إيرادات أخرى', 'name_en' => 'Other Revenue',
'module' => 'payments', 'key' => 'other', 'category' => 'other',
'legacy_account' => '410515',
],
];
}
/**
* Write the declared streams into the table, and add anything found in live
* payments data that was never declared.
*
* @return array{created:int, discovered:array<int,string>}
*/
public static function sync(?\App\Core\Database $db = null): array
{
$db = $db ?? App::getInstance()->db();
$created = 0;
$discovered = [];
foreach (self::definitions() as $code => $def) {
$exists = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code]);
if ($exists) {
continue;
}
$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'],
'is_system' => 1,
'is_active' => 1,
]);
$created++;
}
// Anything transacting in production that we never declared.
$live = $db->select("SELECT DISTINCT payment_type FROM payments WHERE payment_type IS NOT NULL AND payment_type <> ''");
foreach ($live as $row) {
$type = (string) $row['payment_type'];
$code = 'payment:' . $type;
if ($db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$code])) {
continue;
}
$db->insert('revenue_streams', [
'stream_code' => $code,
'name_ar' => $type,
'name_en' => $type,
'source_module' => 'payments',
'source_key' => $type,
'category' => 'other',
'is_system' => 1,
'is_active' => 1,
'notes' => 'تم اكتشافه تلقائيًا من بيانات المدفوعات — يحتاج مراجعة وربط بحساب صحيح',
]);
$created++;
$discovered[] = $type;
}
return ['created' => $created, 'discovered' => $discovered];
}
/** Stream code for a payment_type. */
public static function codeForPaymentType(string $paymentType): string
{
return 'payment:' . $paymentType;
}
}
<?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(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>توزيع: <?= e($stream['name_ar']) ?><?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;"><?= e($stream['name_ar']) ?></h2>
<div style="font-size:12px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($stream['stream_code']) ?></div>
<?php if ($rule): ?>
<div style="margin-top:6px;font-size:12px;color:#6B7280;">
الإصدار الحالي <strong><?= (int) $rule['version'] ?></strong>
— ساري من <?= e($rule['effective_from']) ?>
</div>
<?php else: ?>
<div style="margin-top:6px;"><span class="badge badge-danger">لا توجد قاعدة توزيع — لن يُرحَّل أي قيد</span></div>
<?php endif; ?>
</div>
<form method="POST" action="/accounting/revenue-mapping/<?= (int) $stream['id'] ?>" id="rule-form">
<?= csrf_field() ?>
<input type="hidden" name="lines" id="lines-payload">
<div style="display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:16px;align-items:start;">
<!-- ══════════ LEFT: the rule ══════════ -->
<div>
<!-- Header settings -->
<div class="card" style="margin-bottom:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">إعدادات القاعدة</h3></div>
<div style="padding:16px 18px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<label class="form-label">الحساب المدين (أين ذهب المال)</label>
<select name="debit_source" id="debit-source" class="form-select">
<option value="auto_treasury" <?= (!$rule || $rule['debit_source'] === 'auto_treasury') ? 'selected' : '' ?>>
تلقائي حسب طريقة الدفع والخزنة
</option>
<option value="fixed_account" <?= ($rule && $rule['debit_source'] === 'fixed_account') ? 'selected' : '' ?>>
حساب ثابت
</option>
<option value="accounts_receivable" <?= ($rule && $rule['debit_source'] === 'accounts_receivable') ? 'selected' : '' ?>>
حساب مدينين (استحقاق بدون تحصيل)
</option>
</select>
<div class="form-help">التلقائي = الصندوق للنقدي، البنك للشيك والفيزا والتحويل.</div>
</div>
<div id="debit-account-wrap" style="<?= ($rule && $rule['debit_source'] !== 'auto_treasury') ? '' : 'display:none;' ?>">
<label class="form-label">اختر الحساب المدين</label>
<input type="text" class="form-input acct-search" data-target="debit_account_id"
placeholder="ابحث بالكود أو الاسم"
value="<?= e($debitLabel ?? '') ?>">
<input type="hidden" name="debit_account_id" id="debit_account_id"
value="<?= $rule ? e((string) ($rule['debit_account_id'] ?? '')) : '' ?>">
<div class="acct-results"></div>
</div>
<div>
<label class="form-label">المعالجة الضريبية</label>
<select name="tax_profile_id" id="tax-profile" class="form-select">
<option value="">بدون ضريبة</option>
<?php foreach ($taxProfiles as $tp): ?>
<option value="<?= (int) $tp['id'] ?>"
data-rate="<?= e((string) $tp['rate']) ?>"
data-inclusive="<?= (int) $tp['is_price_inclusive'] ?>"
<?= ($rule && (int) ($rule['tax_profile_id'] ?? 0) === (int) $tp['id']) ? 'selected' : '' ?>>
<?= e($tp['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="form-help">الضريبة تُفصل أولًا كالتزام — ولا تُحسب ضمن الإيراد.</div>
</div>
<div>
<label class="form-label">مركز التكلفة</label>
<select name="cost_center_id" class="form-select">
<option value="">بدون</option>
<?php foreach ($costCenters as $cc): ?>
<option value="<?= (int) $cc['id'] ?>" <?= ($rule && (int) ($rule['cost_center_id'] ?? 0) === (int) $cc['id']) ? 'selected' : '' ?>>
<?= e($cc['code'] . ' — ' . $cc['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">ساري اعتبارًا من</label>
<input type="date" name="effective_from" class="form-input" value="<?= e(date('Y-m-d')) ?>" required>
<div class="form-help">القيود المُرحَّلة قبل هذا التاريخ لا تتغير.</div>
</div>
<div>
<label class="form-label">نطاق خاص (اختياري)</label>
<div style="display:flex;gap:8px;">
<select name="branch_id" class="form-select" style="flex:1;">
<option value="">كل الفروع</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= ($rule && (int) ($rule['branch_id'] ?? 0) === (int) $b['id']) ? 'selected' : '' ?>>
<?= e($b['name_ar']) ?>
</option>
<?php endforeach; ?>
</select>
<select name="payment_method" class="form-select" style="flex:1;">
<option value="">كل الطرق</option>
<?php foreach (['cash' => 'نقدي', 'check' => 'شيك', 'visa' => 'فيزا', 'bank_transfer' => 'تحويل بنكي'] as $k => $lbl): ?>
<option value="<?= e($k) ?>" <?= ($rule && ($rule['payment_method'] ?? '') === $k) ? 'selected' : '' ?>><?= e($lbl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-help">القاعدة الأضيق نطاقًا هي التي تُطبَّق.</div>
</div>
</div>
<div style="margin-top:12px;">
<label class="form-label">ملاحظة / سبب التغيير</label>
<input type="text" name="notes" class="form-input" placeholder="مثال: قرار مجلس الإدارة رقم ٤٥ بتاريخ ...">
</div>
</div>
</div>
<!-- Split lines -->
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:14px;">بنود التوزيع</h3>
<button type="button" id="add-line" class="btn btn-sm btn-secondary">+ إضافة بند</button>
</div>
<div style="padding:8px 18px 4px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;">
<div style="font-size:11.5px;color:#6B7280;line-height:1.8;">
ترتيب التطبيق ثابت: <strong>الضريبة تُفصل أولًا</strong> ← ثم <strong>المبالغ الثابتة</strong>
← ثم <strong>النسب</strong> ← ثم <strong>«الباقي»</strong> الذي يستوعب المتبقي وفروق التقريب.
يجب وجود بند «باقي» واحد بالضبط.
</div>
</div>
<div id="lines-container" style="padding:14px 18px;"></div>
</div>
<div style="margin-top:14px;display:flex;gap:8px;">
<?php if (can('accounting.revenue_mapping.manage')): ?>
<button type="submit" class="btn btn-primary btn-lg">حفظ كإصدار جديد وتفعيله</button>
<?php endif; ?>
<a href="/accounting/revenue-mapping" class="btn btn-ghost">إلغاء</a>
</div>
</div>
<!-- ══════════ RIGHT: live simulation ══════════ -->
<div style="position:sticky;top:14px;">
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">محاكاة القيد</h3></div>
<div style="padding:16px 18px;">
<label class="form-label">مبلغ تجريبي محصَّل</label>
<input type="number" id="sim-amount" class="form-input" value="1000" step="0.01" min="0" dir="ltr" style="text-align:right;font-size:16px;font-weight:600;">
<div class="form-help">اكتب المبلغ لترى القيد الذي سيُرحَّل فعليًا.</div>
<div id="sim-output" style="margin-top:14px;"></div>
</div>
</div>
<?php if (!empty($history)): ?>
<div class="card" style="margin-top:14px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;font-size:14px;">سجل الإصدارات</h3></div>
<div style="padding:8px 0;max-height:280px;overflow:auto;">
<?php foreach ($history as $h): ?>
<div style="padding:8px 18px;border-bottom:1px solid #F3F4F6;font-size:12px;">
<div style="display:flex;justify-content:space-between;gap:8px;">
<strong>إصدار <?= (int) $h['version'] ?></strong>
<span class="badge <?= $h['status'] === 'active' ? 'badge-success' : 'badge-neutral' ?>" style="font-size:10px;">
<?= $h['status'] === 'active' ? 'ساري' : ($h['status'] === 'superseded' ? 'ملغى' : 'مسودة') ?>
</span>
</div>
<div style="color:#6B7280;margin-top:3px;">
من <?= e($h['effective_from']) ?><?= $h['effective_to'] ? ' حتى ' . e($h['effective_to']) : '' ?>
</div>
<?php if (!empty($h['activated_by_name'])): ?>
<div style="color:#9CA3AF;margin-top:2px;">بواسطة <?= e($h['activated_by_name']) ?></div>
<?php endif; ?>
<?php if (!empty($h['notes'])): ?>
<div style="color:#6B7280;margin-top:3px;"><?= e($h['notes']) ?></div>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</form>
<!-- ══════════ Line template ══════════ -->
<template id="line-template">
<div class="rule-line" style="border:1px solid #E5E7EB;border-radius:8px;padding:12px;margin-bottom:10px;background:#fff;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span class="line-index" style="font-weight:600;font-size:13px;color:#374151;"></span>
<div style="display:flex;gap:6px;">
<button type="button" class="btn btn-sm btn-ghost move-up" title="لأعلى"></button>
<button type="button" class="btn btn-sm btn-ghost move-down" title="لأسفل"></button>
<button type="button" class="btn btn-sm btn-ghost remove-line" style="color:#DC2626;">حذف</button>
</div>
</div>
<div style="display:grid;grid-template-columns:150px 130px 1fr;gap:10px;align-items:start;">
<div>
<label class="form-label" style="font-size:11px;">طريقة التوزيع</label>
<select class="form-select f-method">
<option value="remainder">الباقي</option>
<option value="percentage">نسبة %</option>
<option value="fixed">مبلغ ثابت</option>
</select>
</div>
<div class="wrap-value">
<label class="form-label" style="font-size:11px;"><span class="value-label">القيمة</span></label>
<input type="number" class="form-input f-value" step="0.01" min="0" dir="ltr" style="text-align:right;">
</div>
<div>
<label class="form-label" style="font-size:11px;">الحساب</label>
<input type="text" class="form-input acct-search f-acct-search" placeholder="ابحث بالكود أو الاسم">
<input type="hidden" class="f-account-id">
<div class="acct-results"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;margin-top:10px;">
<div>
<label class="form-label" style="font-size:11px;">نوع البند</label>
<select class="form-select f-line-type">
<option value="revenue">إيراد</option>
<option value="deferred_revenue">إيراد مؤجل (يُستحق على فترة)</option>
<option value="passthrough">تحصيل لحساب الغير (التزام)</option>
<option value="receivable_offset">سداد مديونية عضو</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:11px;">وصف البند في القيد</label>
<input type="text" class="form-input f-desc" placeholder="اختياري">
</div>
</div>
<div class="wrap-base" style="margin-top:10px;display:none;">
<label class="form-label" style="font-size:11px;">النسبة تُحسب على</label>
<select class="form-select f-base">
<option value="net_after_fixed">الصافي بعد خصم المبالغ الثابتة</option>
<option value="net_total">الصافي الكامل (بعد الضريبة)</option>
<option value="gross_total">الإجمالي المحصَّل (قبل الضريبة)</option>
</select>
</div>
<div class="wrap-deferral" style="margin-top:10px;display:none;background:#F5F3FF;border:1px solid #DDD6FE;border-radius:6px;padding:10px;">
<div style="font-size:11.5px;color:#5B21B6;margin-bottom:8px;">
الإيراد المؤجل يُسجَّل التزامًا عند التحصيل ويتحقق شهريًا بالتساوي — معيار المحاسبة المصري ٤٨.
</div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:10px;">
<div>
<label class="form-label" style="font-size:11px;">عدد الشهور</label>
<input type="number" class="form-input f-months" min="1" max="120" value="12" dir="ltr" style="text-align:right;">
</div>
<div>
<label class="form-label" style="font-size:11px;">يتحقق في حساب الإيراد</label>
<input type="text" class="form-input acct-search f-recog-search" placeholder="حساب الإيراد النهائي">
<input type="hidden" class="f-recognized-id">
<div class="acct-results"></div>
</div>
</div>
</div>
</div>
</template>
<script>
(function () {
'use strict';
var container = document.getElementById('lines-container');
var tpl = document.getElementById('line-template');
var payload = document.getElementById('lines-payload');
var simAmount = document.getElementById('sim-amount');
var simOut = document.getElementById('sim-output');
var taxSelect = document.getElementById('tax-profile');
var csrf = document.querySelector('input[name="_csrf_token"]');
var existing = <?= json_encode(array_map(static function (array $l): array {
return [
'id' => (int) $l['id'],
'line_type' => $l['line_type'],
'allocation_method' => $l['allocation_method'],
'fixed_amount' => $l['fixed_amount'],
'percentage' => $l['percentage'],
'percentage_base' => $l['percentage_base'],
'account_id' => (int) $l['account_id'],
'account_label' => $l['account_code'] . ' — ' . $l['account_name'],
'description_ar' => $l['description_ar'],
'recognition_method' => $l['recognition_method'],
'recognition_months' => $l['recognition_months'],
'recognized_account_id' => $l['recognized_account_id'],
];
}, $lines), JSON_UNESCAPED_UNICODE) ?>;
function fmt(n) {
return Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ── Account search ──────────────────────────────────────────
function wireAccountSearch(input, hidden, results) {
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))
.then(function (r) { return r.json(); })
.then(function (d) {
results.innerHTML = '';
if (!d.accounts || !d.accounts.length) {
results.innerHTML = '<div style="padding:6px;color:#9CA3AF;font-size:12px;">لا نتائج</div>';
return;
}
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;position:relative;z-index:20;';
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:70px;">' + a.account_code + '</span> ' + a.name_ar;
row.addEventListener('mouseenter', function () { row.style.background = '#F3F4F6'; });
row.addEventListener('mouseleave', function () { row.style.background = '#fff'; });
row.addEventListener('click', function () {
hidden.value = a.id;
input.value = a.account_code + ' — ' + a.name_ar;
results.innerHTML = '';
sync();
});
box.appendChild(row);
});
results.appendChild(box);
});
}, 220);
});
}
// ── Lines ───────────────────────────────────────────────────
function addLine(data) {
var node = tpl.content.cloneNode(true);
var el = node.querySelector('.rule-line');
container.appendChild(node);
var method = el.querySelector('.f-method');
var value = el.querySelector('.f-value');
var wrapVal = el.querySelector('.wrap-value');
var wrapBase = el.querySelector('.wrap-base');
var wrapDef = el.querySelector('.wrap-deferral');
var lineType = el.querySelector('.f-line-type');
var valLabel = el.querySelector('.value-label');
wireAccountSearch(el.querySelector('.f-acct-search'), el.querySelector('.f-account-id'), el.querySelectorAll('.acct-results')[0]);
wireAccountSearch(el.querySelector('.f-recog-search'), el.querySelector('.f-recognized-id'), el.querySelectorAll('.acct-results')[1]);
function refresh() {
var m = method.value;
wrapVal.style.display = (m === 'remainder') ? 'none' : '';
wrapBase.style.display = (m === 'percentage') ? '' : 'none';
valLabel.textContent = (m === 'percentage') ? 'النسبة %' : 'المبلغ';
wrapDef.style.display = (lineType.value === 'deferred_revenue') ? '' : 'none';
}
method.addEventListener('change', function () { refresh(); sync(); });
lineType.addEventListener('change', function () { refresh(); sync(); });
[value, el.querySelector('.f-desc'), el.querySelector('.f-base'), el.querySelector('.f-months')]
.forEach(function (i) { if (i) i.addEventListener('input', sync); });
el.querySelector('.remove-line').addEventListener('click', function () { el.remove(); renumber(); sync(); });
el.querySelector('.move-up').addEventListener('click', function () {
if (el.previousElementSibling) { container.insertBefore(el, el.previousElementSibling); renumber(); sync(); }
});
el.querySelector('.move-down').addEventListener('click', function () {
if (el.nextElementSibling) { container.insertBefore(el.nextElementSibling, el); renumber(); sync(); }
});
if (data) {
method.value = data.allocation_method || 'remainder';
lineType.value = data.line_type || 'revenue';
if (data.allocation_method === 'percentage') value.value = data.percentage;
if (data.allocation_method === 'fixed') value.value = data.fixed_amount;
el.querySelector('.f-base').value = data.percentage_base || 'net_after_fixed';
el.querySelector('.f-desc').value = data.description_ar || '';
el.querySelector('.f-account-id').value = data.account_id || '';
el.querySelector('.f-acct-search').value = data.account_label || '';
if (data.recognition_months) el.querySelector('.f-months').value = data.recognition_months;
if (data.recognized_account_id) el.querySelector('.f-recognized-id').value = data.recognized_account_id;
el.dataset.id = data.id || '';
}
refresh();
renumber();
return el;
}
function renumber() {
container.querySelectorAll('.rule-line').forEach(function (el, i) {
el.querySelector('.line-index').textContent = 'بند ' + (i + 1);
});
}
function collect() {
var out = [];
container.querySelectorAll('.rule-line').forEach(function (el) {
var m = el.querySelector('.f-method').value;
var v = el.querySelector('.f-value').value;
out.push({
id: el.dataset.id || '',
line_type: el.querySelector('.f-line-type').value,
allocation_method: m,
fixed_amount: m === 'fixed' ? v : '',
percentage: m === 'percentage' ? v : '',
percentage_base: el.querySelector('.f-base').value,
account_id: el.querySelector('.f-account-id').value,
description_ar: el.querySelector('.f-desc').value,
recognition_method: el.querySelector('.f-line-type').value === 'deferred_revenue' ? 'straight_line' : 'immediate',
recognition_months: el.querySelector('.f-months').value,
recognized_account_id: el.querySelector('.f-recognized-id').value,
max_amount: ''
});
});
return out;
}
// ── Simulation ──────────────────────────────────────────────
var simTimer = null;
function sync() {
var lines = collect();
payload.value = JSON.stringify(lines);
clearTimeout(simTimer);
simTimer = setTimeout(function () { simulate(lines); }, 200);
}
function simulate(lines) {
var amount = simAmount.value || '0';
var body = new FormData();
body.append('amount', amount);
body.append('lines', JSON.stringify(lines));
body.append('tax_profile_id', taxSelect.value);
if (csrf) body.append('_csrf_token', csrf.value);
fetch('/accounting/revenue-mapping/simulate', {
method: 'POST',
body: body,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrf ? csrf.value : ''
}
})
.then(function (r) { return r.json(); })
.then(render)
.catch(function () { simOut.innerHTML = '<div style="color:#DC2626;font-size:12px;">تعذر تشغيل المحاكاة</div>'; });
}
function render(data) {
if (!data.success) {
simOut.innerHTML = '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:10px;color:#991B1B;font-size:12px;">'
+ (data.errors || ['خطأ']).join('<br>') + '</div>';
return;
}
var r = data.result;
var html = '';
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:#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>';
if (r.errors && r.errors.length) {
html += '<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:6px;padding:10px;margin-bottom:10px;color:#991B1B;font-size:12px;">'
+ r.errors.join('<br>') + '</div>';
}
if (r.warnings && r.warnings.length) {
html += '<div style="background:#FFFBEB;border:1px solid #FDE68A;border-radius:6px;padding:10px;margin-bottom:10px;color:#92400E;font-size:12px;">'
+ r.warnings.join('<br>') + '</div>';
}
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;color:#374151;">القيد الناتج</div>';
html += '<table style="width:100%;border-collapse:collapse;font-size:11.5px;">';
html += '<thead><tr style="background:#F9FAFB;">'
+ '<th style="text-align:right;padding:6px;border-bottom:1px solid #E5E7EB;">الحساب</th>'
+ '<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>';
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;">'
+ '<span style="color:#6B7280;">النقدية / البنك</span> <span style="font-size:10px;color:#9CA3AF;">(حسب طريقة الدفع)</span>'
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + fmt(r.gross) + '</td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;"></td></tr>';
if (Number(r.tax) > 0) {
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;background:#FFFBEB;">'
+ (r.tax_account || 'ضريبة القيمة المضافة') + ' <span style="font-size:10px;color:#92400E;">التزام</span>'
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;background:#FFFBEB;"></td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;background:#FFFBEB;">' + fmt(r.tax) + '</td></tr>';
}
(r.allocations || []).forEach(function (a) {
var tag = '';
if (a.line_type === 'deferred_revenue') tag = ' <span style="font-size:10px;color:#5B21B6;">مؤجل</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>' : '';
html += '<tr><td style="padding:6px;border-bottom:1px solid #F3F4F6;">'
+ '<span style="direction:ltr;display:inline-block;color:#6B7280;min-width:64px;font-size:10px;">' + (a.account_code || '') + '</span> '
+ (a.account_name || '') + tag + hdr
+ '</td><td style="padding:6px;border-bottom:1px solid #F3F4F6;"></td>'
+ '<td style="padding:6px;border-bottom:1px solid #F3F4F6;text-align:left;font-weight:600;">' + fmt(a.amount) + '</td></tr>';
});
var totalCr = Number(r.tax || 0);
(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;">'
+ '<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(totalCr) + '</td></tr>';
html += '</tbody></table>';
html += '<div style="margin-top:8px;font-size:11.5px;color:' + (balanced ? '#059669' : '#DC2626') + ';font-weight:600;">'
+ (balanced ? '✓ القيد متوازن' : '✗ القيد غير متوازن') + '</div>';
simOut.innerHTML = html;
}
// ── Boot ────────────────────────────────────────────────────
document.getElementById('add-line').addEventListener('click', function () { addLine(null); sync(); });
simAmount.addEventListener('input', sync);
taxSelect.addEventListener('change', sync);
document.getElementById('debit-source').addEventListener('change', function () {
document.getElementById('debit-account-wrap').style.display = (this.value === 'auto_treasury') ? 'none' : '';
});
var debitInput = document.querySelector('#debit-account-wrap .acct-search');
if (debitInput) {
wireAccountSearch(debitInput, document.getElementById('debit_account_id'), document.querySelector('#debit-account-wrap .acct-results'));
}
document.getElementById('rule-form').addEventListener('submit', function () { payload.value = JSON.stringify(collect()); });
if (existing.length) {
existing.forEach(addLine);
} else {
addLine(null);
}
sync();
})();
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>توزيع الإيرادات على الحسابات<?php $__template->endSection(); ?>
<?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>
<h2 style="margin:0 0 4px;">توزيع الإيرادات على الحسابات</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:640px;">
كل مبلغ يُحصَّل في النظام يمر من هنا. حدِّد لكل مصدر إيراد الحساب — أو الحسابات — التي يُرحَّل إليها،
بنسبة أو بمبلغ ثابت، مع المعالجة الضريبية والإيراد المؤجل.
</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/accounting/revenue-mapping/diagnostics" class="btn btn-outline">فحص الحالة</a>
<a href="/accounting/revenue-mapping/recognition" class="btn btn-outline">الإيراد المؤجل</a>
<a href="/accounting/revenue-mapping/tax-profiles" class="btn btn-outline">الملفات الضريبية</a>
<?php if (can('accounting.revenue_mapping.manage')): ?>
<form method="POST" action="/accounting/revenue-mapping/sync" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-secondary">مزامنة المصادر</button>
</form>
<?php endif; ?>
</div>
</div>
<!-- Summary tiles -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px;">
<?php
$tiles = [
['مصادر الإيراد', (string) $summary['total'], '#111827', ''],
['مربوطة بحسابات', (string) $summary['mapped'], '#059669', ''],
['غير مربوطة', (string) $summary['unmapped'], $summary['unmapped'] > 0 ? '#DC2626' : '#059669', 'unmapped'],
['موزَّعة على أكثر من حساب', (string) $summary['split'], '#2563EB', 'split'],
['على حساب مجمَّع', (string) $summary['catch_all'], $summary['catch_all'] > 0 ? '#D97706' : '#059669', 'catchall'],
['إيراد مؤجل قائم', money($summary['deferred']), '#7C3AED', ''],
];
foreach ($tiles as [$label, $value, $color, $filter]):
$href = $filter !== '' ? '/accounting/revenue-mapping?status=' . $filter : null;
?>
<?php if ($href): ?><a href="<?= e($href) ?>" style="text-decoration:none;"><?php endif; ?>
<div class="card" style="padding:14px 16px;<?= $href ? 'cursor:pointer;' : '' ?>">
<div style="font-size:12px;color:#6B7280;margin-bottom:6px;"><?= e($label) ?></div>
<div style="font-size:22px;font-weight:700;color:<?= $color ?>;"><?= e($value) ?></div>
</div>
<?php if ($href): ?></a><?php endif; ?>
<?php endforeach; ?>
</div>
<!-- Filters -->
<div class="card" style="padding:14px 16px;margin-bottom:15px;">
<form method="GET" action="/accounting/revenue-mapping" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:1;min-width:200px;">
<label class="form-label">بحث</label>
<input type="text" name="q" class="form-input" value="<?= e($search) ?>" placeholder="اسم المصدر أو الكود">
</div>
<div style="min-width:170px;">
<label class="form-label">التصنيف</label>
<select name="category" class="form-select">
<option value="">الكل</option>
<?php foreach ($categories as $key => $label): ?>
<option value="<?= e($key) ?>" <?= $category === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:170px;">
<label class="form-label">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<option value="unmapped" <?= $status === 'unmapped' ? 'selected' : '' ?>>غير مربوطة</option>
<option value="catchall" <?= $status === 'catchall' ? 'selected' : '' ?>>على حساب مجمَّع</option>
<option value="split" <?= $status === 'split' ? 'selected' : '' ?>>موزَّعة</option>
<option value="review" <?= $status === 'review' ? 'selected' : '' ?>>تحتاج مراجعة</option>
</select>
</div>
<div><button type="submit" class="btn btn-primary">تصفية</button></div>
<?php if ($search !== '' || $category !== '' || $status !== ''): ?>
<div><a href="/accounting/revenue-mapping" class="btn btn-ghost">إلغاء</a></div>
<?php endif; ?>
</form>
</div>
<!-- Streams grouped by category -->
<?php
$grouped = [];
foreach ($streams as $s) {
$grouped[$s['category']][] = $s;
}
?>
<?php if (empty($streams)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
لا توجد مصادر مطابقة — جرّب «مزامنة المصادر» لاكتشاف الأنواع الموجودة في البيانات.
</div>
<?php endif; ?>
<?php foreach ($grouped as $cat => $items): ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;font-size:15px;"><?= e($categories[$cat] ?? $cat) ?></h3>
<span style="color:#6B7280;font-size:12px;"><?= count($items) ?> مصدر</span>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:24%;">مصدر الإيراد</th>
<th style="width:34%;">التوزيع الحالي</th>
<th style="width:12%;">الضريبة</th>
<th style="width:16%;">الحركة الفعلية</th>
<th style="width:14%;"></th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $s): ?>
<tr>
<td>
<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>
<?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;">
<?= e($s['notes']) ?>
</div>
<?php endif; ?>
</td>
<td>
<?php if (!$s['is_mapped']): ?>
<span class="badge badge-danger">غير مربوط</span>
<div style="font-size:11px;color:#DC2626;margin-top:4px;">لن يُرحَّل أي قيد تلقائي لهذا المصدر</div>
<?php else: ?>
<?php foreach ($s['lines'] as $l): ?>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;font-size:12px;">
<span style="min-width:64px;font-weight:600;color:<?= $l['allocation_method'] === 'remainder' ? '#374151' : '#2563EB' ?>;">
<?php if ($l['allocation_method'] === 'percentage'): ?>
<?= rtrim(rtrim(number_format((float) $l['percentage'], 2), '0'), '.') ?>%
<?php elseif ($l['allocation_method'] === 'fixed'): ?>
<?= money($l['fixed_amount']) ?>
<?php else: ?>
الباقي
<?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 if ($l['line_type'] === 'deferred_revenue'): ?>
<span class="badge badge-info" style="font-size:10px;">مؤجل</span>
<?php elseif ($l['line_type'] === 'passthrough'): ?>
<span class="badge badge-neutral" style="font-size:10px;">تحصيل للغير</span>
<?php elseif ($l['line_type'] === 'receivable_offset'): ?>
<span class="badge badge-neutral" style="font-size:10px;">سداد مديونية</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; ?>
<?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; ?>
</td>
<td>
<?php if ($s['volume']['n'] > 0): ?>
<div style="font-weight:600;"><?= money($s['volume']['total']) ?></div>
<div style="font-size:11px;color:#6B7280;"><?= number_format($s['volume']['n']) ?> عملية</div>
<?php else: ?>
<span style="color:#9CA3AF;font-size:12px;"></span>
<?php endif; ?>
</td>
<td style="text-align:left;">
<?php if (can('accounting.revenue_mapping.manage')): ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-primary">
<?= $s['is_mapped'] ? 'تعديل التوزيع' : 'ربط الحسابات' ?>
</a>
<?php else: ?>
<a href="/accounting/revenue-mapping/<?= (int) $s['id'] ?>/edit" class="btn btn-sm btn-outline">عرض</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?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: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);
/**
* Revenue Posting Engine — account determination layer.
*
* Replaces the hardcoded AccountCodes::creditAccountForPaymentType() match statement
* with a configurable, versioned, effective-dated mapping that finance staff control
* from the UI.
*
* Model (mirrors SAP account determination / Dynamics posting profiles / Odoo income
* account mapping, adapted to Egyptian VAT law 67/2016 and EAS 48 revenue recognition):
*
* revenue_streams — every chargeable thing in the ERP (the catalogue)
* revenue_tax_profiles — VAT treatments (rate + inclusive/exclusive + output account)
* revenue_posting_rules — versioned, effective-dated rule head per stream
* revenue_posting_rule_lines — the split components of one rule version
* revenue_posting_log — which rule version produced which journal entry (audit)
* revenue_recognition_schedules — deferred revenue amortisation rows
*/
return function (\App\Core\Database $db): void {
// ── 1. Revenue streams — the catalogue of chargeable things ──────────
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_streams` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`stream_code` VARCHAR(100) NOT NULL COMMENT 'payment:membership_fee, rental:invoice, fine:imposed ...',
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`source_module` VARCHAR(50) NOT NULL COMMENT 'payments, rentals, fines, sales, academies ...',
`source_event` VARCHAR(100) NULL COMMENT 'the EventBus event that triggers it',
`source_key` VARCHAR(100) NULL COMMENT 'payment_type value, or other discriminator',
`category` ENUM(
'membership','subscription','activity','facility','transfer',
'penalty','retail','rental','academy','other'
) NOT NULL DEFAULT 'other',
`default_direction` ENUM('inflow','outflow') NOT NULL DEFAULT 'inflow',
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'auto-discovered, cannot be deleted',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_revenue_stream_code` (`stream_code`),
INDEX `idx_revenue_stream_module` (`source_module`, `is_active`),
INDEX `idx_revenue_stream_lookup` (`source_module`, `source_key`, `is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── 2. Tax profiles — VAT treatment ─────────────────────────────────
// Egyptian VAT law 67/2016: standard 14%, table goods/services at their own
// rates, exempt (معفاة), and out of scope (خارج نطاق الضريبة).
// exempt vs zero_rated is NOT cosmetic: exempt blocks input tax recovery.
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_tax_profiles` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`tax_code` VARCHAR(30) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`treatment` ENUM('standard','table','zero_rated','exempt','out_of_scope') NOT NULL DEFAULT 'standard',
`rate` DECIMAL(7,4) NOT NULL DEFAULT 0.0000 COMMENT 'e.g. 14.0000 for 14%',
`is_price_inclusive` TINYINT(1) NOT NULL DEFAULT 1
COMMENT '1 = the collected amount already contains the tax (extract it); 0 = tax added on top',
`output_tax_account_id` BIGINT UNSIGNED NULL COMMENT 'Cr. VAT payable — liability, never revenue',
`input_tax_account_id` BIGINT UNSIGNED NULL,
`legal_reference` VARCHAR(200) NULL COMMENT 'مادة/قانون',
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_tax_profile_code` (`tax_code`),
INDEX `idx_tax_profile_active` (`is_active`, `effective_from`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── 3. Posting rules — versioned, effective-dated ────────────────────
// A posted journal entry is never retroactively changed by editing a rule.
// Editing creates a NEW version; the old version stays for audit.
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_posting_rules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`stream_id` BIGINT UNSIGNED NOT NULL,
`version` INT UNSIGNED NOT NULL DEFAULT 1,
`name_ar` VARCHAR(200) NULL,
-- Scoping: NULL = applies to everything. More specific rules win.
`branch_id` BIGINT UNSIGNED NULL,
`payment_method` VARCHAR(30) NULL COMMENT 'cash, check, visa, bank_transfer — NULL = any',
`member_category` VARCHAR(50) NULL COMMENT 'reserved for member-type scoping',
-- Debit side (where the money landed). NULL = derive from payment method/treasury.
`debit_account_id` BIGINT UNSIGNED NULL,
`debit_source` ENUM('auto_treasury','fixed_account','accounts_receivable') NOT NULL DEFAULT 'auto_treasury',
-- Tax
`tax_profile_id` BIGINT UNSIGNED NULL COMMENT 'NULL = no tax layer',
`rounding_account_id` BIGINT UNSIGNED NULL COMMENT 'absorbs sub-piastre residue if remainder line cannot',
`cost_center_id` BIGINT UNSIGNED NULL,
`status` ENUM('draft','active','superseded') NOT NULL DEFAULT 'draft',
`effective_from` DATE NOT NULL,
`effective_to` DATE NULL,
`superseded_by_id` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
`activated_at` DATETIME NULL,
`activated_by` BIGINT UNSIGNED NULL,
INDEX `idx_posting_rule_stream` (`stream_id`, `status`, `effective_from`),
INDEX `idx_posting_rule_scope` (`stream_id`, `branch_id`, `payment_method`, `status`),
CONSTRAINT `fk_posting_rule_stream` FOREIGN KEY (`stream_id`) REFERENCES `revenue_streams`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── 4. Rule lines — the split ───────────────────────────────────────
// Evaluation order is fixed and visible: fixed → percentage → remainder.
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_posting_rule_lines` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`rule_id` BIGINT UNSIGNED NOT NULL,
`sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`line_type` ENUM(
'revenue',
'deferred_revenue',
'passthrough',
'contra_revenue',
'receivable_offset'
) NOT NULL DEFAULT 'revenue'
COMMENT 'passthrough = collected for a third party (stamps/ministry) → liability not revenue',
`allocation_method` ENUM('fixed','percentage','remainder') NOT NULL DEFAULT 'remainder',
`fixed_amount` DECIMAL(18,2) NULL COMMENT 'for allocation_method=fixed',
`percentage` DECIMAL(9,4) NULL COMMENT 'for allocation_method=percentage',
`percentage_base` ENUM('net_after_fixed','net_total','gross_total') NOT NULL DEFAULT 'net_after_fixed',
`account_id` BIGINT UNSIGNED NOT NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
-- Revenue recognition (EAS 48 / IFRS 15). Only meaningful for deferred_revenue.
`recognition_method` ENUM('immediate','straight_line') NOT NULL DEFAULT 'immediate',
`recognition_months` SMALLINT UNSIGNED NULL COMMENT 'NULL = derive from source document period',
`recognized_account_id` BIGINT UNSIGNED NULL COMMENT 'the revenue account the deferral unwinds into',
`description_ar` VARCHAR(300) NULL,
`min_amount` DECIMAL(18,2) NULL COMMENT 'skip this line if base below',
`max_amount` DECIMAL(18,2) NULL COMMENT 'cap the allocated amount',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_rule_line_rule` (`rule_id`, `sort_order`),
CONSTRAINT `fk_rule_line_rule` FOREIGN KEY (`rule_id`) REFERENCES `revenue_posting_rules`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── 5. Posting log — audit: which rule version produced which entry ──
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_posting_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`stream_id` BIGINT UNSIGNED NULL,
`rule_id` BIGINT UNSIGNED NULL,
`rule_version` INT UNSIGNED NULL,
`journal_entry_id` BIGINT UNSIGNED NULL,
`source_module` VARCHAR(50) NULL,
`source_reference_type` VARCHAR(50) NULL,
`source_reference_id` BIGINT UNSIGNED NULL,
`gross_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`tax_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`net_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`allocation_snapshot` JSON NULL COMMENT 'the exact lines produced, frozen',
`outcome` ENUM('posted','fallback','failed','simulated') NOT NULL DEFAULT 'posted',
`message` VARCHAR(500) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_posting_log_ref` (`source_reference_type`, `source_reference_id`),
INDEX `idx_posting_log_entry` (`journal_entry_id`),
INDEX `idx_posting_log_outcome` (`outcome`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── 6. Recognition schedules — deferred revenue unwinding ────────────
$db->raw("
CREATE TABLE IF NOT EXISTS `revenue_recognition_schedules` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`stream_id` BIGINT UNSIGNED NULL,
`rule_line_id` BIGINT UNSIGNED NULL,
`source_reference_type` VARCHAR(50) NULL,
`source_reference_id` BIGINT UNSIGNED NULL,
`member_id` BIGINT UNSIGNED NULL,
`deferred_account_id` BIGINT UNSIGNED NOT NULL,
`revenue_account_id` BIGINT UNSIGNED NOT NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`period` CHAR(7) NOT NULL COMMENT 'YYYY-MM the amount is earned in',
`amount` DECIMAL(18,2) NOT NULL,
`description_ar` VARCHAR(300) NULL,
`status` ENUM('pending','recognized','cancelled') NOT NULL DEFAULT 'pending',
`journal_entry_id` BIGINT UNSIGNED NULL,
`recognized_at` DATETIME NULL,
`origin_entry_id` BIGINT UNSIGNED NULL COMMENT 'the collection entry that created the deferral',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_recog_period` (`period`, `status`),
INDEX `idx_recog_source` (`source_reference_type`, `source_reference_id`),
INDEX `idx_recog_origin` (`origin_entry_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
};
<?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