Commit 1bc26cc1 authored by DevPilot's avatar DevPilot

feat(accounting): posting chains + accrual reconciler

Two gaps closed, both of which let real money go unrecorded.

CHAINS — money moving between accounts across several entries

The allocation wizard splits one amount inside one entry. It had no
answer for the same money moving through a sequence of entries as
separate actions happen: cash into a safe, settled to main, banked.
Each hop must clear the account the previous one filled, and nothing
enforced that.

Concretely broken: every cash collection debited one global account
regardless of which safe took it, then the settlement credited an
unmapped `treasury:sub_cash` pointer that fell back to الصندوق بالدولار
— an account no collection had ever touched. The sub-safes were also
pointed at the USD/EUR cash boxes, so 972,791 EGP of pound takings sat
in foreign-currency accounts.

A chain step now declares where it leaves money and which earlier step
it clears; the counter side is derived by re-resolving that step's rule
against the same document, so a chain cannot be authored that fails to
net to zero. Each safe owns a GL account, resolved through one service
both the engine and the chain call. Guards refuse a hop whose two sides
resolve to the same account, or whose type puts both on the same side.

The historic misposting is corrected by a reviewable journal entry —
not by rewriting posted history — and cannot be posted twice.

ACCRUALS — obligations the ledger was never told about

24 revenue streams were marked `needs_code`. 18 are now booked, finding
1,316 claims worth 835,167 EGP that were nowhere in the accounts.

Built as a scanner over the source tables rather than event dispatches
in twelve modules: an event can be missed or misnamed — the coach
payroll listener was bound to a name nothing dispatched — while a
scanner is self-healing, retroactive and idempotent.

Critically, collection here posts revenue directly, so accruing without
releasing would double-count on every future payment. Each accrual is
released when its document is paid, by mirroring the same rule.

Outflows that had no entry at all are wired too: staff loans (an asset,
not an expense), end of service, coach fees, goods receipt (against a
new clearing account, so the invoice does not book inventory twice),
depreciation per asset category, stock variances, asset disposal, and
fine waivers.

6 streams are deliberately left unbooked — no amount or no counterparty
recorded, so any figure would be a guess. They are listed on screen
with what each one needs.

Verified against a scratch copy of production: trial balance nets to
0.00, no unbalanced entries, no postings to header accounts, every pass
idempotent.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 34bbb7fa
......@@ -21,9 +21,13 @@ final class AccountCodes
// Cash (under 1206 = النقدية ومافي حكمها)
const CASH_ON_HAND = '12060101'; // الصندوق بالجنيه المصري
const SUB_TREASURY_CASH = '12060102'; // صندوق الخزنة الفرعية - الأنشطة الرياضية
const CASH_AT_BANK = '12060201'; // البنك الرئيسي - جنيه مصري
// SUB_TREASURY_CASH is deliberately gone. It named 12060102 — الصندوق بالدولار,
// the USD box — as "sub-treasury cash", and every safe in the club shared it.
// Each safe now owns an account, resolved through TreasuryAccountService, so
// there is no single constant that could be right. See Phase_108_002.
// Receivables (under 1203/1204)
const ACCOUNTS_RECEIVABLE = '120301'; // العملاء
const EMPLOYEE_LOANS = '120402'; // سلف عاملين
......@@ -146,28 +150,36 @@ final class AccountCodes
};
}
/**
* The cash account for a till.
*
* Every safe — main included — has its own account, resolved through
* TreasuryAccountService so this legacy path and the posting engine cannot
* disagree about where a till's money lives. They did disagree, and the
* settlement chain could not close as a result.
*
* The old currency guard is gone with the cause it guarded against: safes are
* no longer pointed at الصندوق بالدولار / باليورو, so there is nothing left to
* fall back from. Falling back to a club-wide account was itself the damage —
* it hid a mis-wired safe instead of reporting it.
*/
public static function debitAccountForTreasury(string $method, ?int $treasuryId = null): string
{
if ($method !== 'cash' || $treasuryId === null) {
return self::debitAccountForMethod($method);
}
$db = \App\Core\App::getInstance()->db();
$treasury = $db->selectOne("SELECT account_code, type FROM treasuries WHERE id = ?", [$treasuryId]);
if ($treasury && $treasury['type'] === 'sub') {
$code = $treasury['account_code'] ?: self::SUB_TREASURY_CASH;
$account = $db->selectOne(
"SELECT currency FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$code]
);
if ($account && ($account['currency'] === '' || $account['currency'] === 'EGP')) {
return $code;
}
$accountId = \App\Modules\Accounting\Services\TreasuryAccountService::accountFor($treasuryId);
if ($accountId === null) {
return self::CASH_ON_HAND;
}
return self::CASH_ON_HAND;
$row = \App\Core\App::getInstance()->db()->selectOne(
"SELECT account_code FROM chart_of_accounts WHERE id = ?",
[$accountId]
);
return (string) ($row['account_code'] ?? self::CASH_ON_HAND);
}
public static function creditAccountForPaymentType(string $type): string
......
<?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\AccrualRunner;
/**
* الاستحقاقات — money the club is owed, and money it owes, before anyone pays.
*
* The screen exists because the scanner is invisible when it works. Finance
* needs to be able to see what it has booked, what it has deliberately refused
* to book, and to run it by hand after fixing a mapping rather than waiting for
* the nightly pass.
*/
class AccrualController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.accruals.view');
$db = App::getInstance()->db();
$ready = true;
try {
$row = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'posting_accruals'"
);
$ready = ((int) ($row['n'] ?? 0)) === 1;
} catch (\Throwable) {
$ready = false;
}
$status = $ready ? AccrualRunner::status() : [];
$open = '0.00';
$accrued = '0.00';
foreach ($status as $s) {
$accrued = bcadd($accrued, (string) $s['accrued'], 2);
$open = bcadd($open, (string) $s['open_amount'], 2);
}
// The oldest open claims — the ones nobody has chased.
$oldest = $ready ? $db->select(
"SELECT a.*, m.full_name_ar AS member_name
FROM posting_accruals a
LEFT JOIN members m ON m.id = a.member_id
WHERE a.status = 'open' AND a.accrued_amount > 0
ORDER BY a.due_date ASC, a.accrued_amount DESC
LIMIT 40"
) : [];
$runs = [];
try {
$runs = $db->select(
"SELECT * FROM cron_job_log WHERE job_name = 'AccrualReconcileJob'
ORDER BY id DESC LIMIT 10"
);
} catch (\Throwable) {
$runs = [];
}
return $this->view('Accounting.Views.accruals.index', [
'ready' => $ready,
'status' => $status,
'accrued' => $accrued,
'open' => $open,
'oldest' => $oldest,
'runs' => $runs,
'runners' => AccrualRunner::RUNNERS,
'unbookable' => $ready ? AccrualRunner::unbookable() : [],
'lastResult' => $this->session()->getFlash('_accrual_result'),
]);
}
/**
* Run the scan now.
*
* Deliberately available by hand as well as on the nightly cron: cron ships
* disabled on this deployment, and after correcting a mapping the person who
* fixed it should be able to see the result immediately rather than
* discovering tomorrow whether it worked.
*/
public function run(Request $request): Response
{
$this->authorize('accounting.accruals.manage');
$result = AccrualRunner::runAll();
$accruedClaims = 0;
$accruedTotal = '0.00';
$releasedClaims = 0;
$releasedTotal = '0.00';
$errors = [];
foreach ($result['accrued'] as $runner => $r) {
$accruedClaims += (int) $r['posted'];
$accruedTotal = bcadd($accruedTotal, (string) $r['total'], 2);
if ($r['error'] !== null) {
$errors[] = (AccrualRunner::RUNNERS[$runner] ?? $runner) . ': ' . $r['error'];
}
}
foreach ($result['released'] as $type => $r) {
$releasedClaims += (int) $r['posted'];
$releasedTotal = bcadd($releasedTotal, (string) $r['total'], 2);
if ($r['error'] !== null) {
$errors[] = $type . ': ' . $r['error'];
}
}
$this->session()->flash('_accrual_result', [
'accrued_claims' => $accruedClaims,
'accrued_total' => $accruedTotal,
'released_claims' => $releasedClaims,
'released_total' => $releasedTotal,
'errors' => $errors,
'at' => date('Y-m-d H:i'),
]);
$msg = 'تم الفحص — قيّد ' . $accruedClaims . ' مطالبة بـ' . money($accruedTotal)
. '، وأقفل ' . $releasedClaims . ' بـ' . money($releasedTotal);
$redirect = $this->redirect('/accounting/accruals');
return $errors ? $redirect->withWarning($msg . ' — مع ' . count($errors) . ' مشكلة')
: $redirect->withSuccess($msg);
}
private function session(): \App\Core\Session
{
return App::getInstance()->session();
}
}
<?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\Chain\ChainPostingService;
use App\Modules\Accounting\Services\Chain\ChainRegistry;
use App\Modules\Accounting\Services\Chain\ClearingReconciliationService;
use App\Modules\Accounting\Services\TreasuryAccountService;
/**
* مسار الفلوس — the route money takes, and where it is right now.
*
* The allocation screen answers "this amount, split how". This one answers the
* question that screen cannot: the same money moving through several entries as
* separate things happen to it, and whether each hop actually cleared the
* account the one before it filled.
*/
class PostingChainController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('accounting.chains.view');
$chains = ChainRegistry::all(false);
$health = [];
$parked = [];
foreach ($chains as $c) {
$id = (int) $c['id'];
$h = ChainRegistry::health($id);
$total = '0.00';
$oldest = null;
foreach (ClearingReconciliationService::forChain($id) as $row) {
$total = bcadd($total, $row['balance'], 2);
if ($row['oldest_days'] !== null && ($oldest === null || $row['oldest_days'] > $oldest)) {
$oldest = $row['oldest_days'];
}
}
$health[$id] = [
'ok' => $h['ok'],
'errors' => $h['errors'],
'warnings' => $h['warnings'],
'steps' => count($h['steps']),
];
$parked[$id] = ['total' => $total, 'oldest_days' => $oldest];
}
return $this->view('Accounting.Views.chains.index', [
'chains' => $chains,
'health' => $health,
'parked' => $parked,
'domains' => ChainRegistry::DOMAINS,
'ready' => ChainRegistry::ready(),
'unprovisioned' => TreasuryAccountService::unprovisioned(),
'reclassify' => TreasuryAccountService::previewReclassification(),
'failedHops' => ChainPostingService::failedHops(20),
]);
}
public function show(Request $request, string $id): Response
{
$this->authorize('accounting.chains.view');
$chainId = (int) $id;
$chain = ChainRegistry::find($chainId);
if (!$chain) {
return $this->redirect('/accounting/posting-chains')->withError('السلسلة غير موجودة');
}
$health = ChainRegistry::health($chainId);
// Parked balance per account, keyed so the step rows can pick theirs up.
$clearing = [];
foreach (ClearingReconciliationService::forChain($chainId) as $row) {
$clearing[$row['step_no']][] = $row;
}
$hops = App::getInstance()->db()->select(
"SELECT h.*, s.name_ar AS step_name, e.entry_number
FROM posting_chain_hops h
JOIN posting_chain_steps s ON s.id = h.step_id
LEFT JOIN journal_entries e ON e.id = h.journal_entry_id
WHERE h.chain_id = ?
ORDER BY h.posted_at DESC
LIMIT 40",
[$chainId]
);
return $this->view('Accounting.Views.chains.show', [
'chain' => $chain,
'health' => $health,
'steps' => $health['steps'],
'clearing' => $clearing,
'hops' => $hops,
'domains' => ChainRegistry::DOMAINS,
]);
}
/**
* فين الفلوس دلوقتي — every clearing account across every chain, aged.
*
* This is the screen that catches a broken chain without anyone reading the
* general ledger: a clearing account that stops draining shows up as an
* ageing balance long before it shows up as a wrong number anywhere else.
*/
public function parked(Request $request): Response
{
$this->authorize('accounting.chains.view');
$rows = ClearingReconciliationService::overview();
$total = '0.00';
$overdue = '0.00';
foreach ($rows as $r) {
$total = bcadd($total, $r['balance'], 2);
$overdue = bcadd($overdue, $r['overdue_amount'], 2);
}
// One account's open items, newest first. A clearing account that has
// never drained can hold thousands of them — the club's main cash holds
// every collection it has ever taken — so the table is capped and says
// so rather than rendering a megabyte of rows nobody scrolls to.
$focus = (int) $request->get('account', 0);
$detail = null;
if ($focus > 0) {
foreach ($rows as $r) {
if ($r['account_id'] === $focus) {
$detail = $r;
break;
}
}
}
$detailLimit = 200;
if ($detail !== null) {
$items = $detail['items'];
usort($items, static fn(array $a, array $b): int => strcmp((string) $b['date'], (string) $a['date']));
$detail['items_total'] = count($items);
$detail['items'] = array_slice($items, 0, $detailLimit);
$detail['items_limit'] = $detailLimit;
}
return $this->view('Accounting.Views.chains.parked', [
'rows' => $rows,
'total' => $total,
'overdue' => $overdue,
'buckets' => ClearingReconciliationService::BUCKETS,
'detail' => $detail,
'failed' => ChainPostingService::failedHops(50),
]);
}
// ────────────────────────────────────────────────────────────
// Correcting the sub-treasury mis-posting
// ────────────────────────────────────────────────────────────
public function reclassification(Request $request): Response
{
$this->authorize('accounting.chains.view');
return $this->view('Accounting.Views.chains.reclassify', [
'preview' => TreasuryAccountService::previewReclassification(),
'unprovisioned' => TreasuryAccountService::unprovisioned(),
]);
}
/**
* Post the correction. Deliberately a normal journal entry, so it is visible,
* dated, attributable and reversible like any other — not an UPDATE that
* quietly rewrites what the ledger says happened.
*/
public function applyReclassification(Request $request): Response
{
$this->authorize('accounting.chains.manage');
$date = trim((string) $request->post('entry_date', ''));
if ($date !== '' && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->redirect('/accounting/posting-chains/reclassification')
->withError('تاريخ غير صحيح');
}
$note = trim((string) $request->post('notes', ''));
$result = TreasuryAccountService::postReclassification($date ?: null, $note ?: null);
if (!$result['success']) {
return $this->redirect('/accounting/posting-chains/reclassification')
->withError($result['error'] ?? 'فشل التصحيح');
}
return $this->redirect('/accounting/journal-entries/' . $result['journal_entry_id'])
->withSuccess('تم التصحيح — اتنقل ' . money($result['moved']) . ' جنيه لحسابات الخزائن الصح');
}
}
......@@ -1015,7 +1015,9 @@ class RevenueMappingController extends Controller
'INPUT_TAX' => ['120408', 'ضريبة المدخلات — فواتير الموردين', 'procurement:input_tax'],
'INSURANCE_EXPENSE' => ['310103', 'حصة صاحب العمل في التأمينات — قيد المرتبات', 'payroll:employer_insurance'],
'DEPRECIATION' => ['3316', 'الإهلاكات', null],
'SUB_TREASURY_CASH' => ['12060102', 'الخزنة الفرعية — تسويات الأنشطة', 'treasury:sub_cash'],
// SUB_TREASURY_CASH was retired in Phase_108_002 — each safe now owns
// an account and the cash chain resolves it per document, so there is
// no constant left to check. See «مسار الفلوس» for its health instead.
'DEFERRED_REVENUE' => ['230809', 'الإيرادات المقدمة', null],
'COGS' => ['3172', 'تكلفة البضاعة المباعة', 'sales:cogs'],
];
......
......@@ -170,6 +170,19 @@ return [
['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'],
// ── Posting Chains (where money goes, hop by hop) ───────
// Static paths first: {id:\d+} would otherwise swallow /parked and
// /reclassification before they are ever reached.
['GET', '/accounting/posting-chains', 'Accounting\Controllers\PostingChainController@index', ['auth'], 'accounting.chains.view'],
['GET', '/accounting/posting-chains/parked', 'Accounting\Controllers\PostingChainController@parked', ['auth'], 'accounting.chains.view'],
['GET', '/accounting/posting-chains/reclassification', 'Accounting\Controllers\PostingChainController@reclassification', ['auth'], 'accounting.chains.view'],
['POST', '/accounting/posting-chains/reclassification', 'Accounting\Controllers\PostingChainController@applyReclassification', ['auth', 'csrf'], 'accounting.chains.manage'],
['GET', '/accounting/posting-chains/{id:\d+}', 'Accounting\Controllers\PostingChainController@show', ['auth'], 'accounting.chains.view'],
// ── Accruals (money owed before anyone pays) ───────────
['GET', '/accounting/accruals', 'Accounting\Controllers\AccrualController@index', ['auth'], 'accounting.accruals.view'],
['POST', '/accounting/accruals/run', 'Accounting\Controllers\AccrualController@run', ['auth', 'csrf'], 'accounting.accruals.manage'],
// ── Billing (universal collection) ──────────────────────
['GET', '/accounting/billing', 'Accounting\Controllers\BillingController@index', ['auth'], 'accounting.billing.view'],
['POST', '/accounting/billing/collect', 'Accounting\Controllers\BillingController@collect', ['auth', 'csrf'], 'accounting.billing.collect'],
......
......@@ -6,6 +6,7 @@ namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\Chain\ChainPostingService;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
use App\Modules\Accounting\Services\Revenue\RevenueRecognitionService;
use App\Modules\Accounting\Services\Revenue\RevenueStreamRegistry;
......@@ -1171,15 +1172,33 @@ final class AccountingIntegrationService
$description = 'فاتورة مورد — ' . $invoiceNumber;
// ── Three-way match ─────────────────────────────────────────
// If a goods receipt already booked this stock, it debited inventory
// against the «بضاعة مستلمة لم ترد فاتورتها» clearing account. The
// invoice must then clear THAT, not debit inventory a second time —
// otherwise the warehouse is worth double on the balance sheet.
$goodsAlreadyBooked = self::goodsReceiptPosted($invoice['purchase_order_id'] ?? null);
$debitLabel = 'مخزون';
if ($goodsAlreadyBooked) {
$clearingId = PostingRouter::accountFor('inventory:goods_receipt', '230817', 'accrual');
if ($clearingId === null) {
Logger::error('Vendor invoice: goods-received clearing account unresolved', ['invoice_id' => $invoiceId]);
return;
}
$inventoryAccountId = $clearingId;
$debitLabel = 'إقفال بضاعة مستلمة';
}
$lines = [];
// Dr. Inventory (subtotal)
// Dr. Inventory — or the clearing account when the goods were already booked.
if (bccomp($subtotal, '0.00', 2) > 0) {
$lines[] = [
'account_id' => $inventoryAccountId,
'debit' => $subtotal,
'credit' => '0.00',
'description_ar' => 'مخزون — فاتورة مورد ' . $invoiceNumber,
'description_ar' => $debitLabel . ' — فاتورة مورد ' . $invoiceNumber,
'supplier_id' => $supplierId > 0 ? $supplierId : null,
];
}
......@@ -1533,6 +1552,37 @@ final class AccountingIntegrationService
* The sub-ledger only means anything if it is created alongside the GL entry,
* so this is called with the journal entry id the accrual produced.
*/
/**
* Has a goods receipt for this purchase order already put the stock on the
* books? Checked against the journal rather than the GRN status, because it
* is the posting that matters — a receipt whose entry failed has not booked
* anything, and the invoice must debit inventory normally.
*/
private static function goodsReceiptPosted(mixed $purchaseOrderId): bool
{
$poId = (int) ($purchaseOrderId ?? 0);
if ($poId <= 0) {
return false;
}
try {
$row = App::getInstance()->db()->selectOne(
"SELECT 1 AS ok
FROM goods_received_notes g
JOIN journal_entries e
ON e.reference_type = 'goods_receipt'
AND e.reference_id = g.id
AND e.status IN ('posted', 'reversed')
WHERE g.purchase_order_id = ?
LIMIT 1",
[$poId]
);
return $row !== null;
} catch (\Throwable) {
return false;
}
}
private static function upsertReceivable(
int $memberId,
string $documentType,
......@@ -1925,8 +1975,18 @@ final class AccountingIntegrationService
// ────────────────────────────────────────────────────────────
/**
* Settlement received by main treasury.
* Dr. 12060101 (Main Cash) | Cr. 12060102 (Sub-Treasury Cash)
* Settlement received by main treasury — hop 2 of the cash chain.
*
* Cr the settling safe's own account (relieved)
* Dr the receiving safe's own account (parked)
*
* Both ends come off the settlement document itself, so the credit is
* necessarily the same account the day's collections debited. The previous
* version resolved the credit through a single club-wide `treasury:sub_cash`
* pointer, which was unmapped and fell back to الصندوق بالدولار — an account
* no collection had ever touched. The settlement therefore posted a balanced
* entry that debited main cash a second time and drove the dollar box
* negative, and nothing about it looked wrong.
*/
public static function onTreasurySettlementReceived(array $data): void
{
......@@ -1938,134 +1998,108 @@ final class AccountingIntegrationService
return;
}
// AccountCodes::SUB_TREASURY_CASH points at 12060102, which is actually the
// USD cash box — a sub-treasury settlement posted there would sit in a
// foreign-currency account. Resolve it through a configurable pointer.
$debitAccountId = PostingRouter::accountFor('treasury:main_cash', AccountCodes::CASH_ON_HAND, 'transfer');
$creditAccountId = PostingRouter::accountFor('treasury:sub_cash', null, 'transfer');
if ($creditAccountId === null) {
$sub = self::getAccountByCode(AccountCodes::SUB_TREASURY_CASH);
if ($sub && ($sub['currency'] ?? 'EGP') === 'EGP') {
$creditAccountId = (int) $sub['id'];
}
}
if ($debitAccountId === null || $creditAccountId === null) {
Logger::error("Treasury settlement auto-post failed: accounts unresolved — map treasury:sub_cash", [
'settlement_id' => $settlementId,
]);
$settlement = $db->selectOne(
"SELECT settlement_number, from_treasury_id, to_treasury_id, settled_at
FROM treasury_settlements WHERE id = ?",
[$settlementId]
);
if (!$settlement) {
return;
}
$settlement = $db->selectOne("SELECT settlement_number FROM treasury_settlements WHERE id = ?", [$settlementId]);
$refNumber = $settlement['settlement_number'] ?? '';
$description = 'تسوية من الخزنة الفرعية — ' . $refNumber;
$refNumber = (string) ($settlement['settlement_number'] ?? '');
$fromId = (int) ($data['from_treasury_id'] ?? $settlement['from_treasury_id']);
$toId = (int) ($data['to_treasury_id'] ?? $settlement['to_treasury_id']);
$lines = [
[
'account_id' => $debitAccountId,
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $description,
],
[
'account_id' => $creditAccountId,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => $description,
],
];
$result = JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Sub-treasury settlement — ' . $refNumber,
$result = ChainPostingService::advance('treasury:cash_lifecycle', 2, [
'amount' => $amount,
'entry_date' => substr((string) ($settlement['settled_at'] ?? date('Y-m-d')), 0, 10),
'from_treasury_id' => $fromId,
'to_treasury_id' => $toId,
'treasury_id' => $fromId,
'reference_type' => 'treasury_settlement',
'reference_id' => $settlementId,
'reference_number' => $refNumber,
'source_module' => 'treasury',
'is_auto_generated' => 1,
], $lines, true);
'description_ar' => 'تسوية من الخزنة الفرعية — ' . $refNumber,
'description_en' => 'Sub-treasury settlement — ' . $refNumber,
]);
if ($result['success'] && !empty($result['journal_entry_id'])) {
$db->update('treasury_settlements', ['journal_entry_id' => (int) $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$settlementId]);
if ($result['success'] && $result['journal_entry_id'] !== null) {
$db->update(
'treasury_settlements',
['journal_entry_id' => $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ?',
[$settlementId]
);
} elseif (!$result['success']) {
Logger::error("Treasury settlement journal entry failed", ['settlement_id' => $settlementId, 'error' => $result['error'] ?? 'unknown']);
Logger::error("Treasury settlement journal entry failed", [
'settlement_id' => $settlementId,
'error' => $result['error'] ?? 'unknown',
]);
}
}
/**
* Bank deposit confirmed by accounting manager.
* Dr. 12060201 (Cash at Bank) | Cr. 12060101 (Main Cash)
* Bank deposit confirmed — hop 3, the last one.
*
* Cr the safe the notes left (relieved)
* Dr the bank account they entered (parked — terminal)
*
* The safe comes from the deposit slip rather than being assumed to be main
* cash, so a branch that banks straight from its own till still closes its
* own clearing account instead of draining a safe it never filled.
*/
public static function onTreasuryDepositConfirmed(array $data): void
{
$db = App::getInstance()->db();
$amount = (string) ($data['amount'] ?? '0.00');
$depositId = (int) ($data['deposit_id'] ?? 0);
$bankAccountId = $data['bank_account_id'] ?? null;
if (bccomp($amount, '0.00', 2) <= 0 || $depositId <= 0) {
return;
}
$debitAccountCode = AccountCodes::CASH_AT_BANK;
if ($bankAccountId) {
$bankAccount = $db->selectOne("SELECT gl_account_id FROM bank_accounts WHERE id = ?", [(int) $bankAccountId]);
if ($bankAccount && $bankAccount['gl_account_id']) {
$glAccount = $db->selectOne("SELECT account_code FROM chart_of_accounts WHERE id = ?", [(int) $bankAccount['gl_account_id']]);
if ($glAccount) {
$debitAccountCode = $glAccount['account_code'];
}
}
}
$debitAccount = self::getAccountByCode($debitAccountCode);
$creditAccountId = PostingRouter::accountFor('treasury:main_cash', AccountCodes::CASH_ON_HAND, 'transfer');
if (!$debitAccount || $creditAccountId === null) {
Logger::error("Treasury deposit auto-post failed: accounts unresolved", ['deposit_id' => $depositId]);
$deposit = $db->selectOne(
"SELECT deposit_number, deposit_date, bank_receipt_serial, treasury_id, bank_account_id
FROM treasury_deposits WHERE id = ?",
[$depositId]
);
if (!$deposit) {
return;
}
$deposit = $db->selectOne("SELECT deposit_number, deposit_date, bank_receipt_serial FROM treasury_deposits WHERE id = ?", [$depositId]);
$refNumber = $deposit['deposit_number'] ?? '';
$refNumber = (string) ($deposit['deposit_number'] ?? '');
$description = 'إيداع بنكي — ' . $refNumber;
if (!empty($deposit['bank_receipt_serial'])) {
$description .= ' — سيريال: ' . $deposit['bank_receipt_serial'];
}
$lines = [
[
'account_id' => (int) $debitAccount['id'],
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $description,
],
[
'account_id' => $creditAccountId,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => $description,
],
];
$result = JournalService::createEntry([
'entry_date' => $deposit['deposit_date'] ?? date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Bank deposit — ' . $refNumber,
'reference_type' => 'treasury_deposit',
'reference_id' => $depositId,
'reference_number' => $refNumber,
'source_module' => 'treasury',
'is_auto_generated' => 1,
], $lines, true);
$result = ChainPostingService::advance('treasury:cash_lifecycle', 3, [
'amount' => $amount,
'entry_date' => (string) ($deposit['deposit_date'] ?? date('Y-m-d')),
'treasury_id' => (int) $deposit['treasury_id'],
'bank_account_id' => $data['bank_account_id'] ?? $deposit['bank_account_id'],
'reference_type' => 'treasury_deposit',
'reference_id' => $depositId,
'reference_number' => $refNumber,
'source_module' => 'treasury',
'description_ar' => $description,
'description_en' => 'Bank deposit — ' . $refNumber,
]);
if ($result['success'] && !empty($result['journal_entry_id'])) {
$db->update('treasury_deposits', ['journal_entry_id' => (int) $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [$depositId]);
if ($result['success'] && $result['journal_entry_id'] !== null) {
$db->update(
'treasury_deposits',
['journal_entry_id' => $result['journal_entry_id'], 'updated_at' => date('Y-m-d H:i:s')],
'`id` = ?',
[$depositId]
);
} elseif (!$result['success']) {
Logger::error("Treasury deposit journal entry failed", ['deposit_id' => $depositId, 'error' => $result['error'] ?? 'unknown']);
Logger::error("Treasury deposit journal entry failed", [
'deposit_id' => $depositId,
'error' => $result['error'] ?? 'unknown',
]);
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Chain;
use App\Core\App;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
use App\Modules\Accounting\Services\TreasuryAccountService;
/**
* Turns a chain step's resolver into a concrete, postable account id.
*
* The whole point of a resolver is that some accounts cannot be written down
* once. "The safe that took the money" is a different account for the memberships
* desk than for the sports desk, and a different one again the day the club opens
* a third desk. Freezing an account id into the rule is what produced the bug
* this package exists to close: a settlement crediting a fixed «sub-treasury
* cash» account that no collection had ever debited.
*
* So a step says HOW to find its account, and this class answers WHICH account,
* against the context of the transaction actually being posted.
*
* Every method returns a result rather than throwing. A posting path must be able
* to report "خزنة العضويات ملهاش حساب" to the operator instead of dying, and the
* chain health screen calls the exact same code to show the same message before
* anybody touches money.
*/
final class ChainAccountResolver
{
public const RESOLVERS = [
'treasury_of_txn' => 'حساب الخزنة اللي المعاملة حصلت فيها',
'treasury_source' => 'حساب الخزنة المُحوِّلة (مصدر التسوية)',
'treasury_target' => 'حساب الخزنة المستقبِلة',
'bank_of_txn' => 'حساب البنك اللي في المستند',
'fixed_account' => 'حساب ثابت محدد',
'stream_pointer' => 'مؤشر حساب من شاشة التوزيع',
'allocation_lines' => 'بنود التقسيمة هي اللي تحدد',
'none' => 'لا يوجد — المرحلة دي مش بتحجز فلوس',
];
/**
* @param array $step one row of posting_chain_steps
* @param array $ctx transaction context: treasury_id, from_treasury_id,
* to_treasury_id, bank_account_id, branch_id
*
* @return array{account_id:?int, error:?string, source:string}
*/
public static function parks(array $step, array $ctx): array
{
return self::resolve(
(string) ($step['parks_resolver'] ?? 'none'),
$step['parks_account_id'] ?? null,
$step['parks_pointer'] ?? null,
$ctx
);
}
/**
* The account this step clears.
*
* `inherit` — the common and safest case — walks back to the step named in
* relieves_step_no and resolves ITS parking rule against THIS context. That is
* what makes the chain structurally sound: the credit is not a second opinion
* about which account holds the money, it is the same expression that put it
* there.
*
* @param array $step the step being posted
* @param array $steps every step of the chain, keyed by step_no
*/
public static function relieves(array $step, array $steps, array $ctx): array
{
$resolver = (string) ($step['relieve_resolver'] ?? 'inherit');
if ($resolver === 'none') {
return ['account_id' => null, 'error' => null, 'source' => 'none'];
}
if ($resolver === 'inherit') {
$prevNo = $step['relieves_step_no'] ?? null;
if ($prevNo === null) {
return [
'account_id' => null,
'error' => 'المرحلة مضبوطة على «ترحيل من المرحلة السابقة» بس مفيش مرحلة سابقة محددة',
'source' => 'inherit',
];
}
$prev = $steps[(int) $prevNo] ?? null;
if ($prev === null) {
return [
'account_id' => null,
'error' => 'المرحلة السابقة رقم ' . $prevNo . ' مش موجودة في السلسلة',
'source' => 'inherit',
];
}
// A settlement moves money BETWEEN two safes, so the step it clears is
// "a safe" while this step parks into "another safe". Resolving the
// previous step's rule verbatim would read the wrong end of the
// document, so treasury_of_txn is read as the source side here.
$prevResolver = (string) ($prev['parks_resolver'] ?? 'none');
if ($prevResolver === 'treasury_of_txn' && isset($ctx['from_treasury_id'])) {
$prevResolver = 'treasury_source';
}
$out = self::resolve($prevResolver, $prev['parks_account_id'] ?? null, $prev['parks_pointer'] ?? null, $ctx);
$out['source'] = 'مرحلة ' . $prevNo . ' — ' . ($prev['name_ar'] ?? '');
return $out;
}
return self::resolve(
$resolver,
$step['relieve_account_id'] ?? null,
$step['relieve_pointer'] ?? null,
$ctx
);
}
// ────────────────────────────────────────────────────────────────────
/**
* `stream:code@stage` — the stage matters because a stream's account differs
* by it. `ar:control` names the receivable at accrual and the write-off
* account at writeoff; without the stage the lookup silently reads whichever
* one happens to exist. Defaults to collection, the commonest.
*
* @return array{0:string, 1:string}
*/
private static function splitPointer(string $pointer): array
{
if (!str_contains($pointer, '@')) {
return [$pointer, 'collection'];
}
[$code, $stage] = explode('@', $pointer, 2);
$code = trim($code);
$stage = trim($stage);
return [$code, $stage !== '' ? $stage : 'collection'];
}
/** @return array{account_id:?int, error:?string, source:string} */
public static function resolve(string $resolver, mixed $fixedId, ?string $pointer, array $ctx): array
{
$label = self::RESOLVERS[$resolver] ?? $resolver;
switch ($resolver) {
case 'none':
case 'allocation_lines':
return ['account_id' => null, 'error' => null, 'source' => $label];
case 'fixed_account':
$id = (int) ($fixedId ?? 0);
if ($id <= 0) {
return ['account_id' => null, 'error' => 'الحساب الثابت مش محدد', 'source' => $label];
}
return self::verify($id, $label);
case 'stream_pointer':
if ($pointer === null || $pointer === '') {
return ['account_id' => null, 'error' => 'مؤشر الحساب مش محدد', 'source' => $label];
}
[$code, $stage] = self::splitPointer($pointer);
$id = PostingRouter::accountFor($code, null, $stage);
if ($id === null) {
return [
'account_id' => null,
'error' => 'المؤشر «' . $code . '» مالوش قاعدة مفعّلة في مرحلة «' . $stage
. '» — اظبطه من شاشة توزيع المبالغ',
'source' => $label,
];
}
return self::verify($id, $label . ' (' . $pointer . ')');
case 'treasury_of_txn':
return self::treasury($ctx['treasury_id'] ?? null, $label);
case 'treasury_source':
return self::treasury($ctx['from_treasury_id'] ?? ($ctx['treasury_id'] ?? null), $label);
case 'treasury_target':
return self::treasury($ctx['to_treasury_id'] ?? null, $label);
case 'bank_of_txn':
return self::bank($ctx['bank_account_id'] ?? null, $label);
default:
return ['account_id' => null, 'error' => 'نوع تحديد حساب غير معروف: ' . $resolver, 'source' => $label];
}
}
/**
* A safe's own cash account.
*
* Delegated so the chain and the allocation engine cannot drift apart: both
* ask TreasuryAccountService, which is the only place that knows.
*/
private static function treasury(mixed $treasuryId, string $label): array
{
$id = (int) ($treasuryId ?? 0);
if ($id <= 0) {
return ['account_id' => null, 'error' => 'المستند مش محدد فيه خزنة', 'source' => $label];
}
$r = TreasuryAccountService::resolve($id);
return [
'account_id' => $r['account_id'],
'error' => $r['error'],
'source' => $label . ($r['name'] !== null ? ' — ' . $r['name'] : ''),
];
}
private static function bank(mixed $bankAccountId, string $label): array
{
$id = (int) ($bankAccountId ?? 0);
if ($id <= 0) {
return ['account_id' => null, 'error' => 'المستند مش محدد فيه حساب بنكي', 'source' => $label];
}
$db = App::getInstance()->db();
$bank = $db->selectOne(
"SELECT id, account_name_ar, gl_account_id FROM bank_accounts WHERE id = ?",
[$id]
);
if (!$bank) {
return ['account_id' => null, 'error' => 'الحساب البنكي رقم ' . $id . ' مش موجود', 'source' => $label];
}
if (empty($bank['gl_account_id'])) {
return [
'account_id' => null,
'error' => 'الحساب البنكي «' . $bank['account_name_ar'] . '» مش مربوط بحساب في الدليل',
'source' => $label,
];
}
return self::verify((int) $bank['gl_account_id'], $label . ' — ' . $bank['account_name_ar']);
}
/**
* A header or inactive account is rejected by JournalService at post time,
* which surfaces as a mystery failure. Catch it here so the message names the
* account and the fix.
*/
private static function verify(int $accountId, string $label): array
{
static $cache = [];
if (!\array_key_exists($accountId, $cache)) {
$cache[$accountId] = App::getInstance()->db()->selectOne(
"SELECT id, account_code, name_ar, is_header, is_active, is_archived
FROM chart_of_accounts WHERE id = ?",
[$accountId]
);
}
$acc = $cache[$accountId];
if (!$acc) {
return ['account_id' => null, 'error' => 'الحساب رقم ' . $accountId . ' مش موجود', 'source' => $label];
}
$name = $acc['account_code'] . ' — ' . $acc['name_ar'];
if ((int) $acc['is_header'] === 1) {
return ['account_id' => null, 'error' => 'الحساب ' . $name . ' رئيسي ومش بيقبل ترحيل', 'source' => $label];
}
if ((int) $acc['is_active'] === 0 || (int) $acc['is_archived'] === 1) {
return ['account_id' => null, 'error' => 'الحساب ' . $name . ' موقوف أو مؤرشف', 'source' => $label];
}
return ['account_id' => $accountId, 'error' => null, 'source' => $label];
}
/**
* Every concrete account a resolver can produce, for the reconciliation and
* health screens. A dynamic resolver fans out — treasury_of_txn is not one
* clearing account but one per safe, and each has to be aged separately.
*
* @return array<int, array{account_id:int, label:string, scope_type:?string, scope_id:?int}>
*/
public static function expand(string $resolver, mixed $fixedId, ?string $pointer): array
{
$db = App::getInstance()->db();
$out = [];
switch ($resolver) {
case 'treasury_of_txn':
case 'treasury_source':
case 'treasury_target':
$rows = $db->select(
"SELECT t.id, t.name_ar, t.gl_account_id
FROM treasuries t
WHERE t.is_active = 1 AND t.gl_account_id IS NOT NULL"
);
foreach ($rows as $r) {
$out[] = [
'account_id' => (int) $r['gl_account_id'],
'label' => (string) $r['name_ar'],
'scope_type' => 'treasury',
'scope_id' => (int) $r['id'],
];
}
break;
case 'bank_of_txn':
$rows = $db->select(
"SELECT id, account_name_ar, gl_account_id FROM bank_accounts
WHERE is_active = 1 AND is_archived = 0 AND gl_account_id IS NOT NULL"
);
foreach ($rows as $r) {
$out[] = [
'account_id' => (int) $r['gl_account_id'],
'label' => (string) $r['account_name_ar'],
'scope_type' => 'bank_account',
'scope_id' => (int) $r['id'],
];
}
break;
case 'fixed_account':
$id = (int) ($fixedId ?? 0);
if ($id > 0) {
$a = $db->selectOne("SELECT id, account_code, name_ar FROM chart_of_accounts WHERE id = ?", [$id]);
if ($a) {
$out[] = [
'account_id' => (int) $a['id'],
'label' => $a['account_code'] . ' — ' . $a['name_ar'],
'scope_type' => null,
'scope_id' => null,
];
}
}
break;
case 'stream_pointer':
if ($pointer !== null && $pointer !== '') {
[$code, $stage] = self::splitPointer($pointer);
$id = PostingRouter::accountFor($code, null, $stage);
if ($id !== null) {
$a = $db->selectOne("SELECT id, account_code, name_ar FROM chart_of_accounts WHERE id = ?", [$id]);
if ($a) {
$out[] = [
'account_id' => (int) $a['id'],
'label' => $a['account_code'] . ' — ' . $a['name_ar'],
'scope_type' => null,
'scope_id' => null,
];
}
}
}
break;
}
return $out;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Chain;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Services\JournalService;
/**
* Moving money one hop along a chain.
*
* A hop names two accounts — the one it clears and the one it leaves the money
* in — and says what it does to the balance sheet. Which side each account lands
* on is then derived from the account's own nature rather than written down:
*
* transfer value moves home to +1, from -1
* contract both sides shrink to -1, from -1
* expand both sides grow to +1, from +1
*
* where +1 means "post on this account's natural side" and -1 the opposite. A
* settlement is a transfer between two asset accounts, so it comes out Dr the
* receiving safe / Cr the settling one. Paying a supplier is a contraction of a
* liability against cash, so it comes out Dr the payable / Cr the bank. Neither
* needed a direction flag and neither can be written down backwards, because a
* hop whose two sides do not land on opposite sides is refused rather than
* posted.
*
* Steps with posted_by_chain = 0 are skipped here. They are real hops with real
* accounts — a collection landing in a safe, a cheque going under collection —
* but a dedicated service already posts them, so the chain only records where
* they leave the money. That is what lets a later hop clear exactly the account
* the earlier one filled: both ends read the same resolver, so the settlement's
* credit IS the collection's debit by construction, not by two rules happening
* to name the same account.
*/
final class ChainPostingService
{
private const SCALE = 2;
/** hop_type => [to_account change, from_account change] */
private const HOP_SHAPE = [
'transfer' => ['to' => 1, 'from' => -1],
'contract' => ['to' => -1, 'from' => -1],
'expand' => ['to' => 1, 'from' => 1],
];
/**
* Advance a document one step along a chain.
*
* Never throws: a settlement must still be recordable when its accounts are
* unmapped, otherwise a configuration mistake locks the cashiers out. A
* failure is written to posting_chain_hops with its reason, which is what the
* "stuck money" screen reads — the alternative is money that silently never
* moves and no record of why.
*
* @param array $ctx amount, entry_date, treasury_id | from_treasury_id |
* to_treasury_id, bank_account_id, reference_type,
* reference_id, reference_number, description_ar,
* source_module, branch_id, member_id, supplier_id
*
* @return array{success:bool, journal_entry_id:?int, error:?string, skipped:bool}
*/
public static function advance(string $chainCode, int $stepNo, array $ctx): array
{
try {
return self::run($chainCode, $stepNo, $ctx);
} catch (\Throwable $e) {
Logger::error('Chain hop failed', [
'chain' => $chainCode,
'step' => $stepNo,
'ref' => ($ctx['reference_type'] ?? '') . '#' . ($ctx['reference_id'] ?? ''),
'error' => $e->getMessage(),
]);
return ['success' => false, 'journal_entry_id' => null, 'error' => $e->getMessage(), 'skipped' => false];
}
}
private static function run(string $chainCode, int $stepNo, array $ctx): array
{
$fail = static fn(string $msg): array =>
['success' => false, 'journal_entry_id' => null, 'error' => $msg, 'skipped' => false];
if (!ChainRegistry::ready()) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'جداول السلاسل مش منصّبة', 'skipped' => true];
}
$chain = ChainRegistry::findByCode($chainCode);
if (!$chain) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'السلسلة «' . $chainCode . '» مش معرّفة', 'skipped' => true];
}
$steps = ChainRegistry::steps((int) $chain['id']);
$step = $steps[$stepNo] ?? null;
if (!$step) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'المرحلة رقم ' . $stepNo . ' مش موجودة أو موقوفة', 'skipped' => true];
}
$amount = self::money((string) ($ctx['amount'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'المبلغ صفر أو أقل', 'skipped' => true];
}
// Someone else's job. Calling advance() on one of these is a wiring
// mistake worth reporting rather than a silent no-op, but it must not
// fail the caller's transaction.
if ((int) ($step['posted_by_chain'] ?? 1) === 0) {
return [
'success' => true,
'journal_entry_id' => null,
'error' => null,
'skipped' => true,
];
}
$hopType = (string) ($step['hop_type'] ?? 'transfer');
if (!isset(self::HOP_SHAPE[$hopType])) {
return $fail('نوع حركة غير معروف: ' . $hopType);
}
// ── Already done? ───────────────────────────────────────────────
// Events are re-dispatched more often than anyone expects — a retried
// request, a double-clicked Receive button. Posting the same hop twice
// would drain the clearing account below zero and look like theft.
$existing = self::existingHop((int) $chain['id'], $stepNo, $ctx);
if ($existing !== null) {
return [
'success' => true,
'journal_entry_id' => $existing['journal_entry_id'] !== null ? (int) $existing['journal_entry_id'] : null,
'error' => null,
'skipped' => true,
];
}
// ── Both ends ───────────────────────────────────────────────────
$relieve = ChainAccountResolver::relieves($step, $steps, $ctx);
$parks = ChainAccountResolver::parks($step, $ctx);
if ($relieve['error'] !== null) {
self::log($chain, $step, $ctx, $amount, null, null, null, 'failed', 'الحساب اللي هيترحّل منه: ' . $relieve['error']);
return $fail('الحساب اللي هيترحّل منه: ' . $relieve['error']);
}
if ($parks['error'] !== null) {
self::log($chain, $step, $ctx, $amount, null, null, $relieve['account_id'], 'failed', 'الحساب اللي هيترحّل له: ' . $parks['error']);
return $fail('الحساب اللي هيترحّل له: ' . $parks['error']);
}
if ($relieve['account_id'] === null || $parks['account_id'] === null) {
$msg = 'المرحلة دي مش بتحرّك فلوس بين حسابين — مش ينفع تترحّل لوحدها';
self::log($chain, $step, $ctx, $amount, null, $parks['account_id'], $relieve['account_id'], 'skipped', $msg);
return ['success' => false, 'journal_entry_id' => null, 'error' => $msg, 'skipped' => true];
}
// The guard the treasury settlement never had. Two safes sharing one
// account, or a chain wired back onto itself, produces a balanced entry
// that moves nothing — and reads as a successful settlement for ever.
if ($relieve['account_id'] === $parks['account_id']) {
$msg = 'طرفا القيد نفس الحساب — الحركة مش هتنقل أي فلوس. راجع حسابات المرحلة.';
self::log($chain, $step, $ctx, $amount, null, $parks['account_id'], $relieve['account_id'], 'failed', $msg);
return $fail($msg);
}
// ── Post ────────────────────────────────────────────────────────
$date = $ctx['entry_date'] ?? date('Y-m-d');
$description = $ctx['description_ar']
?? ($step['name_ar'] . (!empty($ctx['reference_number']) ? ' — ' . $ctx['reference_number'] : ''));
$memberId = self::posInt($ctx['member_id'] ?? null);
$supplierId = self::posInt($ctx['supplier_id'] ?? null);
$branchId = self::posInt($ctx['branch_id'] ?? null);
// Each side lands where its own nature and the hop's shape put it. An
// asset being increased is a debit; a liability being decreased is also a
// debit. Nothing here needs to know that a settlement differs from a
// supplier payment — the accounts say so.
$shape = self::HOP_SHAPE[$hopType];
$toSide = self::side($parks['account_id'], $shape['to']);
$fromSide = self::side($relieve['account_id'], $shape['from']);
if ($toSide === null || $fromSide === null) {
$msg = 'مش عارف طبيعة أحد الحسابين (مدين/دائن)';
self::log($chain, $step, $ctx, $amount, null, $parks['account_id'], $relieve['account_id'], 'failed', $msg);
return $fail($msg);
}
// Two debits or two credits means the hop type does not match the accounts
// it was pointed at — a transfer written between an asset and a liability,
// say. Refuse it: JournalService would reject the unbalanced entry anyway,
// but with a message about totals rather than about the mistake.
if ($toSide === $fromSide) {
$msg = 'الحركة نوعها «' . self::hopLabel($hopType) . '» بس الحسابين طبيعتهم بتخلّي الطرفين على نفس الجانب — '
. 'راجع نوع الحركة أو الحسابات.';
self::log($chain, $step, $ctx, $amount, null, $parks['account_id'], $relieve['account_id'], 'failed', $msg);
return $fail($msg);
}
$lines = [
[
'account_id' => $parks['account_id'],
'debit' => $toSide === 'debit' ? $amount : '0.00',
'credit' => $toSide === 'debit' ? '0.00' : $amount,
'description_ar' => $description,
'member_id' => $memberId,
'supplier_id' => $supplierId,
'branch_id' => $branchId,
],
[
'account_id' => $relieve['account_id'],
'debit' => $fromSide === 'debit' ? $amount : '0.00',
'credit' => $fromSide === 'debit' ? '0.00' : $amount,
'description_ar' => $description,
'member_id' => $memberId,
'supplier_id' => $supplierId,
'branch_id' => $branchId,
],
];
$result = JournalService::createEntry([
'entry_date' => $date,
'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'] ?? 'accounting',
'branch_id' => $branchId,
'is_auto_generated' => 1,
'notes' => 'سلسلة «' . $chain['name_ar'] . '» — مرحلة ' . $stepNo . ': ' . $step['name_ar']
. ' (من ' . $relieve['source'] . ' إلى ' . $parks['source'] . ')',
], $lines, true);
if (empty($result['success'])) {
$msg = $result['error'] ?? 'فشل إنشاء القيد';
self::log($chain, $step, $ctx, $amount, null, $parks['account_id'], $relieve['account_id'], 'failed', $msg);
return $fail($msg);
}
$entryId = (int) $result['journal_entry_id'];
self::log($chain, $step, $ctx, $amount, $entryId, $parks['account_id'], $relieve['account_id'], 'posted', null);
return ['success' => true, 'journal_entry_id' => $entryId, 'error' => null, 'skipped' => false];
}
/**
* A hop already posted for this document.
*
* Only a `posted` hop blocks a retry — a previous failure SHOULD be retried
* once the accounts are fixed, which is the whole point of recording it.
*/
private static function existingHop(int $chainId, int $stepNo, array $ctx): ?array
{
$refType = $ctx['reference_type'] ?? null;
$refId = self::posInt($ctx['reference_id'] ?? null);
if ($refType === null || $refId === null) {
return null; // nothing stable to deduplicate on
}
return App::getInstance()->db()->selectOne(
"SELECT id, journal_entry_id FROM posting_chain_hops
WHERE chain_id = ? AND step_no = ? AND reference_type = ? AND reference_id = ?
AND outcome = 'posted'
LIMIT 1",
[$chainId, $stepNo, $refType, $refId]
);
}
private static function log(
array $chain,
array $step,
array $ctx,
string $amount,
?int $entryId,
?int $parkedAccountId,
?int $relievedAccountId,
string $outcome,
?string $message
): void {
try {
App::getInstance()->db()->insert('posting_chain_hops', [
'chain_id' => (int) $chain['id'],
'step_id' => (int) $step['id'],
'step_no' => (int) $step['step_no'],
'journal_entry_id' => $entryId,
'amount' => $amount,
'parked_account_id' => $parkedAccountId,
'relieved_account_id' => $relievedAccountId,
'reference_type' => $ctx['reference_type'] ?? null,
'reference_id' => self::posInt($ctx['reference_id'] ?? null),
'reference_number' => $ctx['reference_number'] ?? null,
'treasury_id' => self::posInt($ctx['treasury_id'] ?? ($ctx['from_treasury_id'] ?? null)),
'branch_id' => self::posInt($ctx['branch_id'] ?? null),
'outcome' => $outcome,
'message' => $message !== null ? mb_substr($message, 0, 500) : null,
'posted_at' => date('Y-m-d H:i:s'),
'created_by' => self::currentEmployeeId(),
]);
} catch (\Throwable $e) {
Logger::error('Chain hop log failed: ' . $e->getMessage());
}
}
/**
* Hops that failed and were never retried — money that is stuck because a
* posting blew up rather than because nobody has settled yet. Different
* problem, different fix, so the screen shows them separately.
*/
public static function failedHops(int $limit = 100): array
{
if (!ChainRegistry::ready()) {
return [];
}
return App::getInstance()->db()->select(
"SELECT h.*, c.name_ar AS chain_name, c.chain_code, s.name_ar AS step_name
FROM posting_chain_hops h
JOIN posting_chains c ON c.id = h.chain_id
JOIN posting_chain_steps s ON s.id = h.step_id
WHERE h.outcome = 'failed'
AND NOT EXISTS (
SELECT 1 FROM posting_chain_hops ok
WHERE ok.chain_id = h.chain_id AND ok.step_no = h.step_no
AND ok.reference_type <=> h.reference_type
AND ok.reference_id <=> h.reference_id
AND ok.outcome = 'posted'
)
ORDER BY h.posted_at DESC
LIMIT " . max(1, min(500, $limit))
);
}
/**
* Which side of the entry an account lands on.
*
* @param int $change +1 to increase the account, -1 to decrease it
* @return 'debit'|'credit'|null
*/
public static function side(int $accountId, int $change): ?string
{
static $nature = [];
if (!\array_key_exists($accountId, $nature)) {
$row = App::getInstance()->db()->selectOne(
"SELECT account_nature FROM chart_of_accounts WHERE id = ?",
[$accountId]
);
$nature[$accountId] = $row['account_nature'] ?? null;
}
return match ($nature[$accountId]) {
'debit' => $change > 0 ? 'debit' : 'credit',
'credit' => $change > 0 ? 'credit' : 'debit',
default => null,
};
}
public static function hopLabel(string $hopType): string
{
return match ($hopType) {
'transfer' => 'نقل بين حسابين',
'contract' => 'سداد/إطفاء (الطرفين بينقصوا)',
'expand' => 'استحقاق (الطرفين بيزيدوا)',
default => $hopType,
};
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
private static function posInt(mixed $v): ?int
{
return ($v === null || $v === '' || (int) $v <= 0) ? null : (int) $v;
}
private static function currentEmployeeId(): ?int
{
try {
$emp = App::getInstance()->currentEmployee();
return $emp ? (int) ($emp->id ?? 0) ?: null : null;
} catch (\Throwable) {
return null;
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Chain;
use App\Core\App;
/**
* Reading, and grading, the configured chains.
*
* Nothing here posts. This is the layer the screens and the health checks share
* with the posting service, so what finance is shown on the chain page is
* produced by the same code that will run when the money actually moves — a
* chain that reads green here cannot fail differently at post time for a reason
* the screen could have known.
*/
final class ChainRegistry
{
public const DOMAINS = [
'treasury' => 'الخزينة والنقدية',
'receivable' => 'الذمم المدينة',
'payable' => 'الذمم الدائنة',
'instrument' => 'الأوراق التجارية',
'inventory' => 'المخزون والأصول',
'payroll' => 'الأجور',
'other' => 'أخرى',
];
/** Cached per request — the tables are absent on an environment that has not migrated. */
private static ?bool $ready = null;
public static function ready(): bool
{
if (self::$ready !== null) {
return self::$ready;
}
try {
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COUNT(*) AS n FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('posting_chains','posting_chain_steps','posting_chain_hops')"
);
self::$ready = ((int) ($row['n'] ?? 0)) === 3;
} catch (\Throwable) {
self::$ready = false;
}
return self::$ready;
}
/** @return array<int, array> */
public static function all(bool $activeOnly = true): array
{
if (!self::ready()) {
return [];
}
$sql = "SELECT * FROM posting_chains";
if ($activeOnly) {
$sql .= " WHERE is_active = 1";
}
$sql .= " ORDER BY FIELD(domain,'treasury','receivable','payable','instrument','inventory','payroll','other'), chain_code";
return App::getInstance()->db()->select($sql);
}
public static function find(int $id): ?array
{
if (!self::ready()) {
return null;
}
return App::getInstance()->db()->selectOne("SELECT * FROM posting_chains WHERE id = ?", [$id]);
}
public static function findByCode(string $code): ?array
{
if (!self::ready()) {
return null;
}
return App::getInstance()->db()->selectOne(
"SELECT * FROM posting_chains WHERE chain_code = ? AND is_active = 1",
[$code]
);
}
/**
* Steps keyed by step_no — the shape ChainAccountResolver::relieves() expects,
* because `inherit` has to look a step up by number.
*
* @return array<int, array>
*/
public static function steps(int $chainId, bool $activeOnly = true): array
{
if (!self::ready()) {
return [];
}
$sql = "SELECT * FROM posting_chain_steps WHERE chain_id = ?";
if ($activeOnly) {
$sql .= " AND is_active = 1";
}
$sql .= " ORDER BY step_no ASC";
$out = [];
foreach (App::getInstance()->db()->select($sql, [$chainId]) as $s) {
$out[(int) $s['step_no']] = $s;
}
return $out;
}
public static function step(int $chainId, int $stepNo): ?array
{
if (!self::ready()) {
return null;
}
return App::getInstance()->db()->selectOne(
"SELECT * FROM posting_chain_steps WHERE chain_id = ? AND step_no = ? AND is_active = 1",
[$chainId, $stepNo]
);
}
// ────────────────────────────────────────────────────────────────────
// Health
// ────────────────────────────────────────────────────────────────────
/**
* Everything that can be wrong with a chain, checked without posting anything.
*
* The check that matters most is the no-op one. A hop whose two sides resolve
* to the same account posts a journal entry that balances, passes every
* validation, appears in the ledger, and moves nothing — which is precisely
* how the treasury settlement went unnoticed. It is graded as an error, not a
* warning.
*
* @return array{errors:array, warnings:array, steps:array, ok:bool}
*/
public static function health(int $chainId): array
{
$steps = self::steps($chainId);
$errors = [];
$warnings = [];
$graded = [];
if (!$steps) {
return [
'errors' => ['السلسلة مفيهاش أي مرحلة'],
'warnings' => [],
'steps' => [],
'ok' => false,
];
}
$entryPoints = array_filter($steps, static fn(array $s): bool => (int) $s['is_entry_point'] === 1);
if (!$entryPoints) {
$errors[] = 'مفيش مرحلة بداية — لازم مرحلة واحدة على الأقل تكون هي اللي الفلوس بتدخل منها';
}
if (!array_filter($steps, static fn(array $s): bool => (int) $s['is_terminal'] === 1)) {
$warnings[] = 'مفيش مرحلة نهاية — يعني الفلوس نظريًا فضلت محجوزة للأبد';
}
foreach ($steps as $no => $step) {
$stepErrors = [];
$stepWarnings = [];
$label = 'مرحلة ' . $no . ' «' . $step['name_ar'] . '»';
// ── Where it parks ──────────────────────────────────────
$parksAccounts = ChainAccountResolver::expand(
(string) $step['parks_resolver'],
$step['parks_account_id'] ?? null,
$step['parks_pointer'] ?? null
);
if ((string) $step['parks_resolver'] !== 'none'
&& (string) $step['parks_resolver'] !== 'allocation_lines'
&& !$parksAccounts) {
$stepErrors[] = self::missingAccountReason(
(string) $step['parks_resolver'],
$step['parks_pointer'] ?? null
);
}
foreach ($parksAccounts as $a) {
$check = ChainAccountResolver::resolve('fixed_account', $a['account_id'], null, []);
if ($check['error'] !== null) {
$stepErrors[] = $a['label'] . ': ' . $check['error'];
}
}
// ── What it clears ──────────────────────────────────────
$relievesNo = $step['relieves_step_no'] ?? null;
if ((int) $step['is_entry_point'] === 0 && $relievesNo === null
&& (string) $step['relieve_resolver'] === 'inherit') {
$stepErrors[] = 'المرحلة مش بداية ومش بترحّل من أي مرحلة قبلها — الفلوس هتظهر من العدم';
}
if ($relievesNo !== null && !isset($steps[(int) $relievesNo])) {
$stepErrors[] = 'بترحّل من مرحلة رقم ' . $relievesNo . ' وهي مش موجودة';
}
if ($relievesNo !== null && (int) $relievesNo >= $no) {
$stepErrors[] = 'بترحّل من مرحلة رقمها أكبر منها أو زيها — الترتيب مقلوب';
}
$relieveAccounts = [];
if ($relievesNo !== null && isset($steps[(int) $relievesNo])
&& (string) $step['relieve_resolver'] === 'inherit') {
$prev = $steps[(int) $relievesNo];
$relieveAccounts = ChainAccountResolver::expand(
(string) $prev['parks_resolver'],
$prev['parks_account_id'] ?? null,
$prev['parks_pointer'] ?? null
);
} elseif ((string) $step['relieve_resolver'] !== 'inherit'
&& (string) $step['relieve_resolver'] !== 'none') {
$relieveAccounts = ChainAccountResolver::expand(
(string) $step['relieve_resolver'],
$step['relieve_account_id'] ?? null,
$step['relieve_pointer'] ?? null
);
}
// ── The no-op check ─────────────────────────────────────
// Only meaningful where both sides are single, static accounts. Two
// dynamic sides (safe → safe) legitimately share a candidate pool;
// that they must differ per document is enforced at post time.
$bothStatic = count($parksAccounts) === 1 && count($relieveAccounts) === 1
&& $parksAccounts[0]['scope_type'] === null && $relieveAccounts[0]['scope_type'] === null;
if ($bothStatic && $parksAccounts[0]['account_id'] === $relieveAccounts[0]['account_id']) {
$stepErrors[] = 'طرفا الحركة نفس الحساب (' . $parksAccounts[0]['label'] . ') — '
. 'القيد هيتوازن ومش هينقل أي فلوس';
}
// ── Does the hop type fit the accounts? ─────────────────
// A transfer between an asset and a liability lands both sides on the
// same side of the entry. Caught here rather than at post time, when
// it would surface as a balance error that names totals, not causes.
$hopType = (string) ($step['hop_type'] ?? 'transfer');
if ($parksAccounts && $relieveAccounts) {
$shape = ['transfer' => [1, -1], 'contract' => [-1, -1], 'expand' => [1, 1]][$hopType] ?? null;
if ($shape === null) {
$stepErrors[] = 'نوع حركة غير معروف: ' . $hopType;
} else {
foreach ($parksAccounts as $pa) {
foreach ($relieveAccounts as $ra) {
if ($pa['account_id'] === $ra['account_id']) {
continue; // same-account case is already reported
}
$to = ChainPostingService::side((int) $pa['account_id'], $shape[0]);
$from = ChainPostingService::side((int) $ra['account_id'], $shape[1]);
if ($to !== null && $to === $from) {
$stepErrors[] = 'نوع الحركة «' . ChainPostingService::hopLabel($hopType)
. '» مع ' . $ra['label'] . ' و' . $pa['label']
. ' بيحطّ الطرفين على نفس الجانب — القيد مش هيتوازن';
break 2;
}
}
}
}
}
// ── Is anything going to fire it? ───────────────────────
// Only asked of hops the chain itself posts. A hop performed by
// another service is fired by that service, not by an event we can
// see from here.
if (empty($step['trigger_event']) && (int) $step['is_entry_point'] === 0
&& (int) ($step['posted_by_chain'] ?? 1) === 1) {
$stepWarnings[] = 'مفيش حدث بيشغّل المرحلة دي — مش هتتنفّذ تلقائيًا';
}
if (!empty($step['stream_code'])) {
$stream = App::getInstance()->db()->selectOne(
"SELECT wiring_status, name_ar FROM revenue_streams WHERE stream_code = ?",
[$step['stream_code']]
);
if ($stream && $stream['wiring_status'] === 'needs_code') {
$stepWarnings[] = 'المصدر «' . $stream['name_ar'] . '» لسه محتاج ربط برمجي — المرحلة دي مش هتوصلها فلوس';
}
}
$graded[$no] = $step + [
'parks_accounts' => $parksAccounts,
'relieve_accounts' => $relieveAccounts,
'errors' => $stepErrors,
'warnings' => $stepWarnings,
];
foreach ($stepErrors as $e) {
$errors[] = $label . ': ' . $e;
}
foreach ($stepWarnings as $w) {
$warnings[] = $label . ': ' . $w;
}
}
return [
'errors' => $errors,
'warnings' => $warnings,
'steps' => $graded,
'ok' => !$errors,
];
}
/**
* Why a resolver produced nothing, said as the thing to go and fix.
*
* "مش لاقي الحساب" sends someone hunting through the chart of accounts. What
* they usually need to hear is that no bank account has been set up yet, or
* that a safe is missing its account — a different screen, and a two-minute
* job once you know which.
*/
private static function missingAccountReason(string $resolver, ?string $pointer): string
{
return match ($resolver) {
'bank_of_txn' =>
'مفيش أي حساب بنكي معرّف ومربوط بحساب في الدليل — عرّفه من «الحسابات البنكية» '
. 'وبعدين اربطه بحسابه في دليل الحسابات، وإلا الإيداع مش هيلاقي مكان ينزل فيه.',
'treasury_of_txn', 'treasury_source', 'treasury_target' =>
'مفيش أي خزنة ليها حساب خاص بيها — شغّل الترحيلات عشان كل خزنة تاخد حسابها.',
'stream_pointer' =>
'المؤشر «' . ($pointer ?? '—') . '» مالوش قاعدة مفعّلة — اظبطه من شاشة توزيع المبالغ.',
'fixed_account' =>
'الحساب الثابت مش محدد أو مش موجود في الدليل.',
default =>
'مش لاقي الحساب اللي المرحلة دي بتحجز فيه الفلوس ('
. (ChainAccountResolver::RESOLVERS[$resolver] ?? $resolver) . ')',
};
}
/**
* Health for every chain at once, for the index page and the diagnostics
* screen. Kept to counts so the list stays one query per chain rather than
* one per step.
*/
public static function healthSummary(): array
{
$out = [];
foreach (self::all() as $chain) {
$h = self::health((int) $chain['id']);
$out[(int) $chain['id']] = [
'ok' => $h['ok'],
'errors' => count($h['errors']),
'warnings' => count($h['warnings']),
'steps' => count($h['steps']),
'first' => $h['errors'][0] ?? ($h['warnings'][0] ?? null),
];
}
return $out;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Chain;
use App\Core\App;
/**
* "Where is the money right now, and how long has it been sitting there?"
*
* A clearing account is any account a chain parks money in on the way to
* somewhere else — a safe before its settlement, main cash before its deposit,
* cheques under collection before the bank pays them. Healthy ones drain. The
* failure mode of a broken chain is not an error message; it is an account that
* quietly stops draining, and a trial balance that still adds up.
*
* Balances alone do not show that. An account holding 40,000 is fine if it is
* today's takings and alarming if 12,000 of it has been there since March. So
* the residual is aged by FIFO: walk the account's postings in order, let each
* credit consume the oldest unconsumed debits, and whatever is left in the queue
* is what is genuinely still parked — with the date and document of each piece.
*
* FIFO is not a guess here. Cash is fungible and settlements are made in order,
* so the oldest money in a safe is the money that leaves first. That is both the
* standard treatment and, for this club's flow, literally what happens.
*/
final class ClearingReconciliationService
{
private const SCALE = 2;
/**
* Entry statuses that count toward what an account holds.
*
* A reversal does not delete the entry it reverses — it posts the opposite,
* and the pair nets to nothing. So both have to be counted, or the reversal
* lands on the account a second time with nothing to cancel.
*
* Note this differs from LedgerService and the statement reports, which read
* `status = 'posted'` only and would therefore subtract a reversed entry
* twice. No entry has been reversed in this database yet, so that has never
* bitten — but it will the first time someone reverses one, and it is a
* wider change than this package should make unasked.
*/
private const COUNTED_STATUSES = "('posted','reversed')";
/** Buckets in days, matching how finance actually asks the question. */
public const BUCKETS = [
['label' => 'اليوم', 'from' => 0, 'to' => 0],
['label' => '١–٣ أيام', 'from' => 1, 'to' => 3],
['label' => '٤–٧ أيام', 'from' => 4, 'to' => 7],
['label' => '٨–٣٠ يوم', 'from' => 8, 'to' => 30],
['label' => 'أكتر من شهر', 'from' => 31, 'to' => null],
];
/**
* Every clearing account across every active chain, with its parked balance
* and aging.
*
* @return array<int, array>
*/
public static function overview(): array
{
$out = [];
foreach (ChainRegistry::all() as $chain) {
foreach (self::forChain((int) $chain['id']) as $row) {
$key = $row['account_id'];
// One account can be the clearing point of more than one chain
// (main cash is the settlement's destination and the deposit's
// source). Age it once, and name every chain that relies on it.
if (isset($out[$key])) {
$out[$key]['chains'][] = $chain['name_ar'];
continue;
}
$out[$key] = $row + ['chains' => [$chain['name_ar']]];
}
}
uasort($out, static fn(array $a, array $b): int => bccomp($b['balance'], $a['balance'], self::SCALE));
return array_values($out);
}
/**
* The clearing accounts of one chain — every non-terminal step that parks
* money, expanded over its concrete accounts.
*/
public static function forChain(int $chainId): array
{
$steps = ChainRegistry::steps($chainId);
// One account often sits under more than one step: main cash is where a
// cashier can collect directly AND where a settlement lands. Picking one
// of those arbitrarily would age it against the wrong deadline and flag
// perfectly normal money as late. So the account is reported once,
// naming every step it plays a part in, and judged against the most
// generous of their deadlines — the only one we can assert is breached.
$byAccount = [];
foreach ($steps as $no => $step) {
if ((int) $step['is_terminal'] === 1) {
continue; // money is meant to rest here — nothing to chase
}
if (\in_array((string) $step['parks_resolver'], ['none', 'allocation_lines'], true)) {
continue;
}
$accounts = ChainAccountResolver::expand(
(string) $step['parks_resolver'],
$step['parks_account_id'] ?? null,
$step['parks_pointer'] ?? null
);
$days = $step['expected_clearing_days'] !== null ? (int) $step['expected_clearing_days'] : null;
foreach ($accounts as $a) {
$id = (int) $a['account_id'];
if (!isset($byAccount[$id])) {
$byAccount[$id] = [
'account_id' => $id,
'account_label' => $a['label'],
'scope_type' => $a['scope_type'],
'scope_id' => $a['scope_id'],
'step_no' => $no,
'step_names' => [],
'next_steps' => [],
'expected_days' => $days,
'unbounded' => $days === null,
];
}
$byAccount[$id]['step_names'][] = (string) $step['name_ar'];
$next = self::nextStepName($steps, $no);
if ($next !== null) {
$byAccount[$id]['next_steps'][] = $next;
}
// A step with no stated deadline means "we have not said how long
// this may sit", which cannot be used to call anything late.
if ($days === null) {
$byAccount[$id]['unbounded'] = true;
} elseif ($byAccount[$id]['expected_days'] !== null) {
$byAccount[$id]['expected_days'] = max($byAccount[$id]['expected_days'], $days);
}
}
}
$rows = [];
foreach ($byAccount as $r) {
$aged = self::age($r['account_id']);
$expected = $r['unbounded'] ? null : $r['expected_days'];
$rows[] = [
'account_id' => $r['account_id'],
'account_label' => $r['account_label'],
'scope_type' => $r['scope_type'],
'scope_id' => $r['scope_id'],
'step_no' => $r['step_no'],
'step_name' => implode(' / ', array_unique($r['step_names'])),
'next_step_name' => $r['next_steps'] ? implode(' / ', array_unique($r['next_steps'])) : null,
'expected_days' => $expected,
'balance' => $aged['balance'],
'items' => $aged['items'],
'buckets' => $aged['buckets'],
'oldest_days' => $aged['oldest_days'],
'overdue_amount' => $expected !== null ? self::olderThan($aged['items'], $expected) : '0.00',
];
}
return $rows;
}
/**
* FIFO-age one account's residual balance.
*
* @return array{balance:string, items:array, buckets:array, oldest_days:?int}
*/
public static function age(int $accountId, ?string $asOf = null): array
{
$db = App::getInstance()->db();
$asOf = $asOf ?? date('Y-m-d');
$lines = $db->select(
"SELECT l.debit, l.credit, e.entry_date, e.entry_number, e.id AS entry_id,
e.description_ar, e.reference_type, e.reference_id, e.reference_number
FROM journal_entry_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
WHERE l.account_id = ?
AND e.status IN " . self::COUNTED_STATUSES . "
AND e.entry_date <= ?
ORDER BY e.entry_date ASC, e.id ASC, l.line_number ASC",
[$accountId, $asOf]
);
// The open queue: debits not yet consumed by a later credit. It empties
// whenever the account is fully cleared, so a chain that works keeps this
// small no matter how many years of history sit behind it.
$queue = [];
$credits = '0.00'; // credits with nothing left to consume — an overdrawn account
foreach ($lines as $l) {
$dr = self::money((string) $l['debit']);
$cr = self::money((string) $l['credit']);
if (bccomp($dr, '0.00', self::SCALE) > 0) {
$queue[] = [
'date' => (string) $l['entry_date'],
'entry_id' => (int) $l['entry_id'],
'entry_number' => (string) $l['entry_number'],
'description' => (string) ($l['description_ar'] ?? ''),
'reference_type' => $l['reference_type'],
'reference_id' => $l['reference_id'],
'reference_number' => $l['reference_number'],
'amount' => $dr,
];
continue;
}
if (bccomp($cr, '0.00', self::SCALE) <= 0) {
continue;
}
$remaining = $cr;
while (bccomp($remaining, '0.00', self::SCALE) > 0 && $queue) {
$head = &$queue[0];
if (bccomp($head['amount'], $remaining, self::SCALE) <= 0) {
$remaining = bcsub($remaining, $head['amount'], self::SCALE);
unset($head);
array_shift($queue);
} else {
$head['amount'] = bcsub($head['amount'], $remaining, self::SCALE);
$remaining = '0.00';
unset($head);
}
}
// More credited out than was ever debited in. On a clearing account
// that is the signature of the bug this package fixes: a hop relieving
// an account the entry point never charged.
if (bccomp($remaining, '0.00', self::SCALE) > 0) {
$credits = bcadd($credits, $remaining, self::SCALE);
}
}
$balance = '0.00';
$items = [];
$today = new \DateTimeImmutable($asOf);
foreach ($queue as $q) {
$balance = bcadd($balance, $q['amount'], self::SCALE);
$age = (int) $today->diff(new \DateTimeImmutable($q['date']))->days;
$items[] = $q + ['age_days' => $age];
}
if (bccomp($credits, '0.00', self::SCALE) > 0) {
$balance = bcsub($balance, $credits, self::SCALE);
}
return [
'balance' => $balance,
'unmatched_credit' => $credits,
'items' => $items,
'buckets' => self::bucket($items),
'oldest_days' => $items ? max(array_column($items, 'age_days')) : null,
];
}
private static function bucket(array $items): array
{
$out = [];
foreach (self::BUCKETS as $b) {
$total = '0.00';
foreach ($items as $i) {
$age = (int) $i['age_days'];
if ($age >= $b['from'] && ($b['to'] === null || $age <= $b['to'])) {
$total = bcadd($total, $i['amount'], self::SCALE);
}
}
$out[] = $b + ['amount' => $total];
}
return $out;
}
private static function olderThan(array $items, int $days): string
{
$total = '0.00';
foreach ($items as $i) {
if ((int) $i['age_days'] > $days) {
$total = bcadd($total, $i['amount'], self::SCALE);
}
}
return $total;
}
private static function nextStepName(array $steps, int $afterNo): ?string
{
foreach ($steps as $no => $s) {
if ($no > $afterNo && (int) $s['is_branch'] === 0
&& (int) ($s['relieves_step_no'] ?? 0) === $afterNo) {
return (string) $s['name_ar'];
}
}
foreach ($steps as $no => $s) {
if ($no > $afterNo && (int) $s['is_branch'] === 0) {
return (string) $s['name_ar'];
}
}
return null;
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
......@@ -275,7 +275,12 @@ final class JournalService
[$entryId]
);
// Create reversed lines (swap debit/credit)
// Create reversed lines (swap debit/credit).
//
// The line-level reference is carried across too. Anything that counts
// what a tagged line did — the treasury reclassification check, AR/AP
// matching — has to see the reversal, or it concludes the original still
// stands and refuses to let the work be redone.
$reversedLines = [];
foreach ($lines as $line) {
$reversedLines[] = [
......@@ -289,6 +294,8 @@ final class JournalService
'member_id' => $line['member_id'],
'employee_id' => $line['employee_id'],
'supplier_id' => $line['supplier_id'],
'reference_type' => $line['reference_type'] ?? null,
'reference_id' => $line['reference_id'] ?? null,
];
}
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Models\JournalEntry;
use App\Modules\Accounting\Services\Revenue\AccrualService;
use App\Modules\Accounting\Services\Revenue\PostingRouter;
/**
* Ledger entries for the operations that move money out, or move value about.
*
* These are the counterparts of the accrual scanner. The scanner reconciles
* obligations that sit in a table waiting to be noticed; these are one-off
* events with a definite moment — a loan handed over, a cheque written for end
* of service, goods arriving at the gate — where the event IS the accounting
* fact and there is nothing to reconcile against later.
*
* Every one of them was previously invisible to the ledger. Cash left the club
* for staff loans and end-of-service with no entry at all; stock arrived and
* increased the warehouse without increasing assets; depreciation was written to
* the asset register and nowhere else, so the fixed assets on the balance sheet
* never aged.
*
* All of them route through PostingRouter, so finance re-points any of these
* accounts from the allocation screen without a code change, and none of them
* throws into its caller: a loan must still be disbursable when its account is
* unmapped. Failures are logged and surface on the diagnostics screen.
*/
final class OperationalPostingService
{
private const SCALE = 2;
// ────────────────────────────────────────────────────────────────────
// HR
// ────────────────────────────────────────────────────────────────────
/**
* A staff loan is not an expense — it is money the club expects back, so it
* moves from cash into a receivable from the employee. Booking it as a cost
* would understate both profit and assets by the whole amount.
*/
public static function onLoanDisbursed(array $data): void
{
$loanId = (int) ($data['loan_id'] ?? 0);
$amount = self::money((string) ($data['amount'] ?? '0'));
if ($loanId <= 0 || bccomp($amount, '0.00', self::SCALE) <= 0) {
return;
}
self::post('hr:loan_disbursement', 'payment', [
'amount' => $amount,
'entry_date' => $data['disbursed_date'] ?? date('Y-m-d'),
'reference_type' => 'hr_loan',
'reference_id' => $loanId,
'reference_number' => $data['loan_number'] ?? null,
'employee_id' => $data['employee_id'] ?? null,
'source_module' => 'hr',
'description_ar' => 'صرف سلفة موظف' . (!empty($data['loan_number']) ? ' — ' . $data['loan_number'] : ''),
]);
}
/** End-of-service gratuity paid out — the single largest HR payment there is. */
public static function onEndOfServicePaid(array $data): void
{
$recordId = (int) ($data['record_id'] ?? 0);
$amount = self::money((string) ($data['amount'] ?? '0'));
if ($recordId <= 0 || bccomp($amount, '0.00', self::SCALE) <= 0) {
return;
}
self::post('hr:end_of_service', 'payment', [
'amount' => $amount,
'entry_date' => $data['paid_date'] ?? date('Y-m-d'),
'reference_type' => 'hr_end_of_service',
'reference_id' => $recordId,
'employee_id' => $data['employee_id'] ?? null,
'source_module' => 'hr',
'description_ar' => 'صرف مستحقات نهاية الخدمة',
]);
}
/**
* Coach fees, accrued on approval rather than on payment.
*
* Approval is the point at which the club accepts it owes the money, and the
* coaching has already been delivered — waiting for the cheque would push the
* cost into whichever month the payment happened to clear.
*
* This listener is bound to `coach.payment.approved`, which is what
* CoachPaymentService actually dispatches. The stream was previously
* described as wired to a different event name and consequently never fired.
*/
public static function onCoachPaymentApproved(array $data): void
{
$paymentId = (int) ($data['payment_id'] ?? 0);
$amount = self::money((string) ($data['net_amount'] ?? '0'));
if ($paymentId <= 0 || bccomp($amount, '0.00', self::SCALE) <= 0) {
return;
}
self::post('hr:coach_payment', 'accrual', [
'amount' => $amount,
'entry_date' => date('Y-m-d'),
'reference_type' => 'coach_payment',
'reference_id' => $paymentId,
'source_module' => 'coaches',
'description_ar' => 'مستحقات مدرب — دورة ' . ($data['period'] ?? ''),
]);
}
// ────────────────────────────────────────────────────────────────────
// Inventory & assets
// ────────────────────────────────────────────────────────────────────
/**
* Goods arriving increase inventory against a clearing account, not against
* the supplier. The supplier is credited when the invoice is approved, and
* that entry debits the same clearing account — see
* AccountingIntegrationService::onVendorInvoiceApproved, which switches to
* the clearing account when a receipt already booked the goods.
*/
public static function onGoodsReceived(array $data): void
{
$grnId = (int) ($data['grn_id'] ?? 0);
if ($grnId <= 0) {
return;
}
$db = App::getInstance()->db();
$grn = $db->selectOne(
"SELECT id, grn_number, total_accepted_value, supplier_id, received_date, purchase_order_id, branch_id
FROM goods_received_notes WHERE id = ?",
[$grnId]
);
if (!$grn) {
return;
}
// Accepted value, not received value: goods rejected at inspection go
// back to the supplier and were never the club's to book.
$amount = self::money((string) ($grn['total_accepted_value'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
return;
}
self::post('inventory:goods_receipt', 'accrual', [
'amount' => $amount,
'entry_date' => substr((string) ($grn['received_date'] ?? date('Y-m-d')), 0, 10),
'reference_type' => 'goods_receipt',
'reference_id' => $grnId,
'reference_number' => $grn['grn_number'] ?? null,
'supplier_id' => $grn['supplier_id'] ?? null,
'branch_id' => $grn['branch_id'] ?? null,
'source_module' => 'procurement',
'description_ar' => 'استلام بضاعة — ' . ($grn['grn_number'] ?? ('#' . $grnId)),
]);
}
/**
* Monthly depreciation, posted per asset category.
*
* Category is not a nicety here: the chart carries a separate accumulated
* depreciation account for buildings, vehicles, furniture and computers, and
* one blended entry would make the fixed-asset note impossible to produce.
* `asset_categories` already carries `expense_account_id` and
* `depreciation_account_id`; a category missing either is reported rather
* than folded into someone else's.
*/
public static function onDepreciationRun(array $data): void
{
$period = (string) ($data['period_month'] ?? date('Y-m'));
$db = App::getInstance()->db();
// Already posted for this period?
if (JournalEntry::findByReference('depreciation_run', self::periodKey($period))) {
return;
}
$rows = $db->select(
"SELECT c.id, c.name_ar, c.expense_account_id, c.depreciation_account_id,
SUM(d.depreciation_amount) AS total
FROM depreciation_entries d
JOIN asset_register a ON a.id = d.asset_id
LEFT JOIN asset_categories c ON c.id = a.category_id
WHERE d.period_month = ?
GROUP BY c.id, c.name_ar, c.expense_account_id, c.depreciation_account_id",
[$period]
);
$lines = [];
$skipped = [];
foreach ($rows as $r) {
$amount = self::money((string) ($r['total'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$expenseId = (int) ($r['expense_account_id'] ?? 0);
$accumId = (int) ($r['depreciation_account_id'] ?? 0);
if ($expenseId <= 0 || $accumId <= 0) {
$skipped[] = ($r['name_ar'] ?? 'فئة غير محددة') . ' (' . $amount . ')';
continue;
}
$lines[] = [
'account_id' => $expenseId,
'debit' => $amount,
'credit' => '0.00',
'description_ar' => 'إهلاك ' . $period . ' — ' . ($r['name_ar'] ?? ''),
];
$lines[] = [
'account_id' => $accumId,
'debit' => '0.00',
'credit' => $amount,
'description_ar' => 'مجمع إهلاك ' . $period . ' — ' . ($r['name_ar'] ?? ''),
];
}
if ($skipped) {
Logger::error('Depreciation not posted for some categories — accounts unmapped', [
'period' => $period,
'categories' => $skipped,
]);
}
if (count($lines) < 2) {
return;
}
$result = JournalService::createEntry([
'entry_date' => date('Y-m-t', strtotime($period . '-01')),
'description_ar' => 'إهلاك الأصول الثابتة — ' . $period,
'description_en' => 'Fixed asset depreciation — ' . $period,
'reference_type' => 'depreciation_run',
'reference_id' => self::periodKey($period),
'reference_number' => $period,
'source_module' => 'inventory',
'is_auto_generated' => 1,
], $lines, true);
if (empty($result['success'])) {
Logger::error('Depreciation entry failed', ['period' => $period, 'error' => $result['error'] ?? null]);
}
}
/**
* Stock count differences hit profit directly, so they belong in the ledger
* on the day the count is approved — not only in the stock movement log.
*
* A shortage is a loss; a surplus reduces it. Both sides are netted into one
* entry because a count is one event, and the direction is decided by the
* net so the entry never needs a sign convention of its own.
*/
public static function onStockAuditApproved(array $data): void
{
$auditId = (int) ($data['audit_id'] ?? 0);
if ($auditId <= 0) {
return;
}
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(variance_cost), 0) AS net
FROM stock_audit_items
WHERE audit_id = ? AND status IN ('counted', 'approved') AND variance <> 0",
[$auditId]
);
$net = self::money((string) ($row['net'] ?? '0'));
if (bccomp(ltrim($net, '-'), '0.01', self::SCALE) < 0) {
return; // nothing material
}
$audit = $db->selectOne("SELECT audit_number FROM stock_audits WHERE id = ?", [$auditId]);
$isShortage = bccomp($net, '0.00', self::SCALE) < 0;
$abs = ltrim($net, '-');
// A shortage takes value out of inventory; a surplus puts it back.
self::post('inventory:stock_variance', $isShortage ? 'writeoff' : 'accrual', [
'amount' => $abs,
'entry_date' => date('Y-m-d'),
'reference_type' => 'stock_audit',
'reference_id' => $auditId,
'reference_number' => $audit['audit_number'] ?? null,
'source_module' => 'inventory',
'description_ar' => ($isShortage ? 'عجز جرد — ' : 'زيادة جرد — ')
. ($audit['audit_number'] ?? ('#' . $auditId)),
]);
}
/**
* Disposing of an asset takes its cost and its accumulated depreciation off
* the books together, and whatever the difference is against the proceeds is
* the gain or loss. Posting only the proceeds — which is what the disposal
* column amounted to — leaves a fully written-off asset sitting in fixed
* assets for ever.
*/
public static function onAssetDisposed(array $data): void
{
$assetId = (int) ($data['asset_id'] ?? 0);
if ($assetId <= 0) {
return;
}
if (JournalEntry::findByReference('asset_disposal', $assetId)) {
return;
}
$db = App::getInstance()->db();
$asset = $db->selectOne(
"SELECT a.id, a.asset_tag, a.purchase_cost, a.accumulated_depreciation,
a.disposal_value, a.disposed_at, a.category_id, a.branch_id,
c.asset_account_id, c.depreciation_account_id,
i.name_ar AS item_name
FROM asset_register a
LEFT JOIN asset_categories c ON c.id = a.category_id
LEFT JOIN inventory_items i ON i.id = a.item_id
WHERE a.id = ?",
[$assetId]
);
if (!$asset) {
return;
}
$cost = self::money((string) ($asset['purchase_cost'] ?? '0'));
$accum = self::money((string) ($asset['accumulated_depreciation'] ?? '0'));
$proceeds = self::money((string) ($data['disposal_value'] ?? $asset['disposal_value'] ?? '0'));
$assetAccount = (int) ($asset['asset_account_id'] ?? 0);
$accumAccount = (int) ($asset['depreciation_account_id'] ?? 0);
if ($assetAccount <= 0 || $accumAccount <= 0) {
Logger::error('Asset disposal not posted — category accounts unmapped', [
'asset_id' => $assetId,
'category' => $asset['category_id'] ?? null,
]);
return;
}
$cashAccount = PostingRouter::accountFor('treasury:method_cash', AccountCodes::CASH_ON_HAND, 'collection');
$lossAccount = PostingRouter::accountFor('inventory:asset_disposal', '3320', 'writeoff');
if ($cashAccount === null || $lossAccount === null) {
Logger::error('Asset disposal not posted — cash or disposal account unresolved', ['asset_id' => $assetId]);
return;
}
$netBook = bcsub($cost, $accum, self::SCALE);
$result = bcsub($proceeds, $netBook, self::SCALE); // + gain, − loss
$name = $asset['item_name'] ?: ($asset['asset_tag'] ?: ('#' . $assetId));
$desc = 'استبعاد أصل — ' . $name;
$lines = [];
if (bccomp($proceeds, '0.00', self::SCALE) > 0) {
$lines[] = ['account_id' => $cashAccount, 'debit' => $proceeds, 'credit' => '0.00', 'description_ar' => 'حصيلة بيع — ' . $name];
}
if (bccomp($accum, '0.00', self::SCALE) > 0) {
$lines[] = ['account_id' => $accumAccount, 'debit' => $accum, 'credit' => '0.00', 'description_ar' => 'إقفال مجمع الإهلاك — ' . $name];
}
if (bccomp($result, '0.00', self::SCALE) < 0) {
$lines[] = ['account_id' => $lossAccount, 'debit' => ltrim($result, '-'), 'credit' => '0.00', 'description_ar' => 'خسارة استبعاد — ' . $name];
}
$lines[] = ['account_id' => $assetAccount, 'debit' => '0.00', 'credit' => $cost, 'description_ar' => 'إقفال تكلفة الأصل — ' . $name];
if (bccomp($result, '0.00', self::SCALE) > 0) {
$gainAccount = PostingRouter::accountFor('inventory:asset_disposal', '410515', 'collection');
if ($gainAccount === null) {
Logger::error('Asset disposal gain account unresolved', ['asset_id' => $assetId]);
return;
}
$lines[] = ['account_id' => $gainAccount, 'debit' => '0.00', 'credit' => $result, 'description_ar' => 'أرباح استبعاد — ' . $name];
}
if (count($lines) < 2) {
return;
}
$result = JournalService::createEntry([
'entry_date' => substr((string) ($asset['disposed_at'] ?? date('Y-m-d')), 0, 10),
'description_ar' => $desc,
'reference_type' => 'asset_disposal',
'reference_id' => $assetId,
'source_module' => 'inventory',
'is_auto_generated' => 1,
], $lines, true);
if (empty($result['success'])) {
Logger::error('Asset disposal entry failed', ['asset_id' => $assetId, 'error' => $result['error'] ?? null]);
}
}
// ────────────────────────────────────────────────────────────────────
// Corrections
// ────────────────────────────────────────────────────────────────────
/**
* Waiving a fine cancels the claim, so the debt has to come off the books.
* Previously the fine changed status and the receivable stayed, so the club
* went on reporting money it had explicitly decided not to collect — and
* kept chasing the member for it.
*/
public static function onFineWaived(array $data): void
{
$fineId = (int) ($data['fine_id'] ?? 0);
if ($fineId <= 0) {
return;
}
$reason = (string) ($data['reason'] ?? 'إعفاء من غرامة');
$reversed = AccrualService::reverse('fine', $fineId, $reason, 'fine');
if (!$reversed) {
Logger::info('Fine waiver had no accrual to reverse', ['fine_id' => $fineId]);
}
}
// ────────────────────────────────────────────────────────────────────
/** Route through the posting engine, never throwing into the caller. */
private static function post(string $streamCode, string $stage, array $ctx): void
{
try {
$refType = (string) ($ctx['reference_type'] ?? '');
$refId = (int) ($ctx['reference_id'] ?? 0);
if ($refType !== '' && $refId > 0 && JournalEntry::findByReference($refType, $refId)) {
return; // already posted
}
$routed = PostingRouter::attempt($streamCode, $stage, $ctx);
if ($routed['handled'] && $routed['journal_entry_id'] === null) {
Logger::error('Operational posting failed', [
'stream' => $streamCode,
'stage' => $stage,
'ref' => $refType . '#' . $refId,
]);
}
} catch (\Throwable $e) {
Logger::error('Operational posting threw', [
'stream' => $streamCode,
'error' => $e->getMessage(),
]);
}
}
/** A stable numeric reference for a period, so a run posts once. */
private static function periodKey(string $period): int
{
return (int) str_replace('-', '', substr($period, 0, 7));
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services\Revenue;
use App\Core\App;
use App\Core\Logger;
/**
* Finding the money the ledger has not been told about, and telling it.
*
* Every one of these obligations was already being recorded somewhere: an
* unpaid subscription row, a booked court, a rental invoice. What was missing
* was the accrual — so the club's books showed neither what it was owed nor the
* income it had earned, until and unless somebody happened to pay.
*
* This is deliberately a SCANNER and not a set of event listeners bolted onto
* twelve modules. A scanner is:
*
* self-healing an event that was never dispatched, was dispatched under the
* wrong name, or fired before its transaction committed, is
* simply picked up on the next pass. That is not theoretical —
* the coach payroll listener in this codebase was bound to an
* event name nothing dispatched, and nobody noticed for months.
* retroactive it books the backlog that accumulated while nothing was
* wired, instead of only catching new obligations.
* testable one place to read, one place to verify, no cross-module
* ordering to reason about.
*
* Every pass is idempotent — posting_accruals records what the ledger already
* believes — so this is safe to run nightly, twice, or by hand after a fix.
*
* What it will NOT do is invent revenue. Where the source records no amount, or
* no identifiable obligation, the scanner reports the gap rather than guessing
* a number to post. Those cases are listed in `unbookable()`.
*/
final class AccrualRunner
{
private const SCALE = 2;
/** Every runner, in the order a nightly pass should take them. */
public const RUNNERS = [
'subscriptions' => 'اشتراكات الأعضاء السنوية',
'subscriptionDevFees' => 'رسوم التنمية',
'subscriptionFines' => 'غرامات تأخير الاشتراكات',
'sportsBookings' => 'حجوزات الملاعب غير المدفوعة',
'sportsSubscriptions' => 'اشتراكات النشاط الرياضي',
'lockerRentals' => 'إيجارات اللوكرات',
'reservations' => 'حجوزات المرافق',
'rentalInvoices' => 'فواتير إيجار المحلات',
'tournamentFees' => 'رسوم الاشتراك في البطولات',
'academyDeposits' => 'تأمينات عقود الأكاديميات',
'academyRent' => 'إيجار الأكاديميات الشهري',
];
/**
* A full pass: book what is newly owed, then close what has been paid.
*
* Order matters. A document raised and settled between two runs has to be
* accrued and released in the same pass — release first and the accrual
* would be booked afterwards with nothing left to close it, leaving a
* receivable standing against money already in the bank.
*/
public static function runAll(): array
{
$out = ['accrued' => [], 'released' => []];
foreach (array_keys(self::RUNNERS) as $runner) {
try {
$out['accrued'][$runner] = self::$runner();
} catch (\Throwable $e) {
Logger::error('Accrual runner failed', ['runner' => $runner, 'error' => $e->getMessage()]);
$out['accrued'][$runner] = self::result(0, 0, '0.00', null, $e->getMessage());
}
}
$out['released'] = self::releases();
return $out;
}
// ────────────────────────────────────────────────────────────────────
// Member subscriptions
// ────────────────────────────────────────────────────────────────────
/**
* The annual subscription run raises hundreds of debts at once. That is ONE
* accounting event, not one per member: an entry per member would bury the
* journal and take minutes to post for no gain, because every report that
* wants the per-member split reads the subledger.
*
* The fine is excluded here and accrued separately — it is a different kind
* of income, earned on a different date, and it is waivable without
* disturbing the subscription itself.
*/
public static function subscriptions(): array
{
$rows = App::getInstance()->db()->select(
"SELECT s.id, s.member_id, s.financial_year, s.person_name, s.person_type,
s.total_amount, s.created_at
FROM subscriptions s
JOIN members m ON m.id = s.member_id
WHERE s.total_amount > 0
AND s.status IN ('pending', 'overdue', 'partial')
ORDER BY s.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => (int) $r['member_id'],
'amount' => (string) $r['total_amount'],
'document_number' => (string) $r['financial_year'],
'due_date' => self::financialYearDue((string) $r['financial_year']),
'description_ar' => 'اشتراك سنوي ' . $r['financial_year'] . ' — ' . ($r['person_name'] ?? ''),
];
}
return self::post('subscription:annual_accrual', $items, [
'document_type' => 'subscription',
'source_module' => 'subscriptions',
'description_ar' => 'استحقاق اشتراكات الأعضاء السنوية',
'reference_type' => 'subscription_accrual',
]);
}
/**
* The development fee is billed and collected as its own payment type, with
* its own revenue account, and `subscriptions.total_amount` does not include
* it. So it is a separate claim — folding it into the subscription accrual
* would credit the wrong account and leave the fee's own collection with
* nothing to clear.
*/
public static function subscriptionDevFees(): array
{
$rows = App::getInstance()->db()->select(
"SELECT s.id, s.member_id, s.financial_year, s.person_name, s.development_fee
FROM subscriptions s
JOIN members m ON m.id = s.member_id
WHERE s.development_fee > 0
AND s.status IN ('pending', 'overdue', 'partial')
ORDER BY s.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => (int) $r['member_id'],
'amount' => (string) $r['development_fee'],
'document_number' => (string) $r['financial_year'],
'due_date' => self::financialYearDue((string) $r['financial_year']),
'description_ar' => 'رسم تنمية ' . $r['financial_year'] . ' — ' . ($r['person_name'] ?? ''),
];
}
return self::post('payment:development_fee', $items, [
'document_type' => 'subscription_dev_fee',
'source_module' => 'subscriptions',
'description_ar' => 'استحقاق رسوم التنمية',
'reference_type' => 'subscription_dev_fee_accrual',
]);
}
/**
* Late fines are recalculated as they grow, so this posts the difference.
* A fine that went from 50 to 75 contributes 25 and its claim is restated to
* 75 — posting 75 again would book the same penalty twice.
*/
public static function subscriptionFines(): array
{
$rows = App::getInstance()->db()->select(
"SELECT s.id, s.member_id, s.financial_year, s.person_name, s.fine_amount
FROM subscriptions s
JOIN members m ON m.id = s.member_id
WHERE s.fine_amount > 0
AND s.status IN ('pending', 'overdue', 'partial')
ORDER BY s.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => (int) $r['member_id'],
'amount' => (string) $r['fine_amount'],
'document_number' => (string) $r['financial_year'],
'description_ar' => 'غرامة تأخير اشتراك ' . $r['financial_year'] . ' — ' . ($r['person_name'] ?? ''),
];
}
return self::post('subscription:late_fee', $items, [
'document_type' => 'subscription_fine',
'source_module' => 'subscriptions',
'description_ar' => 'استحقاق غرامات تأخير الاشتراكات',
'reference_type' => 'subscription_fine_accrual',
]);
}
// ────────────────────────────────────────────────────────────────────
// Sports activity
// ────────────────────────────────────────────────────────────────────
/**
* A booked court is earned income whether or not the counter got round to
* collecting. Cancelled bookings are excluded — nothing is owed on those.
*
* The booker is often not a member (a school, a company, a walk-in), so the
* member link is set only when it genuinely is one. The rest are identified
* by name in posting_accruals.
*/
public static function sportsBookings(): array
{
$rows = App::getInstance()->db()->select(
"SELECT b.id, b.booking_number, b.booking_date, b.total_amount,
b.booker_type, b.booker_id, b.booker_name, b.organization_name, b.branch_id
FROM sa_bookings b
WHERE b.total_amount > 0
AND COALESCE(b.payment_status, 'unpaid') <> 'paid'
AND COALESCE(b.status, '') NOT IN ('cancelled', 'postponed')
ORDER BY b.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => self::memberIdIfMember($r['booker_type'] ?? '', $r['booker_id'] ?? null),
'counterparty_name' => $r['organization_name'] ?: ($r['booker_name'] ?? null),
'amount' => (string) $r['total_amount'],
'document_number' => $r['booking_number'] ?? null,
'due_date' => (string) $r['booking_date'],
'branch_id' => $r['branch_id'] ?? null,
'description_ar' => 'حجز ملعب ' . ($r['booking_number'] ?? '') . ' — ' . ($r['booker_name'] ?? ''),
];
}
return self::post('sa:hourly_booking', $items, [
'document_type' => 'sa_booking',
'source_module' => 'sports_activity',
'description_ar' => 'استحقاق حجوزات الملاعب',
'reference_type' => 'sa_booking_accrual',
]);
}
public static function sportsSubscriptions(): array
{
$rows = App::getInstance()->db()->select(
"SELECT s.id, s.subscription_number, s.period_start, s.period_end, s.final_amount,
s.player_id, p.member_id, p.full_name_ar
FROM sa_subscriptions s
LEFT JOIN sa_players p ON p.id = s.player_id
WHERE s.final_amount > 0
AND COALESCE(s.payment_status, 'unpaid') IN ('unpaid', 'overdue', 'partial')
ORDER BY s.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['member_id']) ? (int) $r['member_id'] : null,
'counterparty_name' => $r['full_name_ar'] ?? null,
'amount' => (string) $r['final_amount'],
'document_number' => $r['subscription_number'] ?? null,
'due_date' => (string) ($r['period_start'] ?? date('Y-m-d')),
'description_ar' => 'اشتراك نشاط رياضي ' . ($r['subscription_number'] ?? '')
. ' — ' . ($r['full_name_ar'] ?? ''),
];
}
return self::post('sa:monthly_subscription', $items, [
'document_type' => 'sa_subscription',
'source_module' => 'sports_activity',
'description_ar' => 'استحقاق اشتراكات النشاط الرياضي',
'reference_type' => 'sa_subscription_accrual',
]);
}
public static function lockerRentals(): array
{
$rows = App::getInstance()->db()->select(
"SELECT r.id, r.rental_number, r.start_date, r.end_date, r.amount,
r.player_id, p.member_id, p.full_name_ar
FROM sa_locker_rentals r
LEFT JOIN sa_players p ON p.id = r.player_id
WHERE r.amount > 0
AND COALESCE(r.payment_status, 'unpaid') <> 'paid'
AND COALESCE(r.is_archived, 0) = 0
ORDER BY r.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['member_id']) ? (int) $r['member_id'] : null,
'counterparty_name' => $r['full_name_ar'] ?? null,
'amount' => (string) $r['amount'],
'document_number' => $r['rental_number'] ?? null,
'due_date' => (string) ($r['start_date'] ?? date('Y-m-d')),
'description_ar' => 'إيجار لوكر ' . ($r['rental_number'] ?? '') . ' — ' . ($r['full_name_ar'] ?? ''),
];
}
return self::post('sa:locker_rental', $items, [
'document_type' => 'sa_locker_rental',
'source_module' => 'sports_activity',
'description_ar' => 'استحقاق إيجارات اللوكرات',
'reference_type' => 'sa_locker_accrual',
]);
}
// ────────────────────────────────────────────────────────────────────
// Facilities & rentals
// ────────────────────────────────────────────────────────────────────
public static function reservations(): array
{
$rows = App::getInstance()->db()->select(
"SELECT r.id, r.reservation_number, r.reservation_date, r.total_amount,
r.member_id, r.booker_name
FROM reservations r
WHERE r.total_amount > 0
AND r.payment_id IS NULL
AND COALESCE(r.status, '') NOT IN ('cancelled', 'no_show')
AND COALESCE(r.is_archived, 0) = 0
ORDER BY r.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['member_id']) ? (int) $r['member_id'] : null,
'counterparty_name' => $r['booker_name'] ?? null,
'amount' => (string) $r['total_amount'],
'document_number' => $r['reservation_number'] ?? null,
'due_date' => (string) ($r['reservation_date'] ?? date('Y-m-d')),
'description_ar' => 'حجز مرفق ' . ($r['reservation_number'] ?? '') . ' — ' . ($r['booker_name'] ?? ''),
];
}
return self::post('facility:reservation', $items, [
'document_type' => 'reservation',
'source_module' => 'reservations',
'description_ar' => 'استحقاق حجوزات المرافق',
'reference_type' => 'reservation_accrual',
]);
}
/**
* A rental invoice is a claim the moment it is raised — that is what an
* invoice is. Collection later relieves it; it does not create the income.
*/
public static function rentalInvoices(): array
{
$rows = App::getInstance()->db()->select(
"SELECT i.id, i.invoice_number, i.period_start, i.due_date, i.total_amount, i.entity_id
FROM rental_invoices i
WHERE i.total_amount > 0
AND COALESCE(i.status, '') <> 'paid'
ORDER BY i.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'amount' => (string) $r['total_amount'],
'document_number' => $r['invoice_number'] ?? null,
'due_date' => (string) ($r['due_date'] ?? date('Y-m-d')),
'description_ar' => 'فاتورة إيجار ' . ($r['invoice_number'] ?? ''),
];
}
return self::post('rental:monthly_invoice', $items, [
'document_type' => 'rental_invoice',
'source_module' => 'rentals',
'description_ar' => 'استحقاق فواتير إيجار المحلات',
'reference_type' => 'rental_invoice_accrual',
]);
}
/**
* Tournament entry fees.
*
* The whole accounting layer for these was already built — listener, account
* and rule — and nothing ever dispatched the event, so it sat idle. The fee
* is on the tournament and the registration links to its payment, which is
* everything needed to tell who still owes.
*/
public static function tournamentFees(): array
{
$rows = App::getInstance()->db()->select(
"SELECT p.id, p.team_name, p.registration_date, t.entry_fee, t.name_ar AS tournament_name,
pl.member_id, pl.full_name_ar
FROM tournament_participants p
JOIN tournaments t ON t.id = p.tournament_id
LEFT JOIN sa_players pl ON pl.id = p.player_id
WHERE t.entry_fee > 0
AND p.payment_id IS NULL
AND COALESCE(p.status, '') NOT IN ('withdrawn', 'cancelled', 'rejected')
ORDER BY p.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'member_id' => !empty($r['member_id']) ? (int) $r['member_id'] : null,
'counterparty_name' => $r['team_name'] ?: ($r['full_name_ar'] ?? null),
'amount' => (string) $r['entry_fee'],
'due_date' => substr((string) ($r['registration_date'] ?? date('Y-m-d')), 0, 10),
'description_ar' => 'رسم اشتراك بطولة ' . ($r['tournament_name'] ?? '')
. ' — ' . ($r['team_name'] ?: ($r['full_name_ar'] ?? '')),
];
}
return self::post('tournament:registration_fee', $items, [
'document_type' => 'tournament_participant',
'source_module' => 'tournaments',
'description_ar' => 'استحقاق رسوم الاشتراك في البطولات',
'reference_type' => 'tournament_fee_accrual',
]);
}
// ────────────────────────────────────────────────────────────────────
// Academy contracts
// ────────────────────────────────────────────────────────────────────
/**
* A deposit held is NOT income. The club owes it back at the end of the
* contract, so it belongs on the liability side — «تأمينات للغير» — and
* recognising it as revenue would overstate the result by the full amount
* and hide a real obligation.
*/
public static function academyDeposits(): array
{
$rows = App::getInstance()->db()->select(
"SELECT c.id, c.contract_number, c.start_date, c.deposit_amount, c.branch_id,
a.name_ar AS academy_name
FROM sa_academy_contracts c
LEFT JOIN sa_academies a ON a.id = c.academy_id
WHERE c.deposit_amount > 0
AND COALESCE(c.deposit_status, '') = 'paid'
AND COALESCE(c.is_archived, 0) = 0
ORDER BY c.id"
);
$items = [];
foreach ($rows as $r) {
$items[] = [
'document_id' => (int) $r['id'],
'counterparty_name' => $r['academy_name'] ?? null,
'amount' => (string) $r['deposit_amount'],
'document_number' => $r['contract_number'] ?? null,
'due_date' => (string) ($r['start_date'] ?? date('Y-m-d')),
'branch_id' => $r['branch_id'] ?? null,
'description_ar' => 'تأمين عقد أكاديمية ' . ($r['contract_number'] ?? '')
. ' — ' . ($r['academy_name'] ?? ''),
];
}
return self::post('academy:contract_deposit', $items, [
'document_type' => 'academy_contract_deposit',
'source_module' => 'academy_contracts',
'description_ar' => 'تأمينات عقود الأكاديميات المحصّلة',
'reference_type' => 'academy_deposit_accrual',
]);
}
/**
* Monthly rent earned on academy contracts, accrued per elapsed month.
*
* Only months that have actually finished are booked. Accruing the whole
* contract on day one would pull years of income into one period; accruing
* the current month before it ends would book rent for days not yet served.
*/
public static function academyRent(): array
{
$rows = App::getInstance()->db()->select(
"SELECT c.id, c.contract_number, c.start_date, c.end_date, c.fixed_monthly_rent,
c.status, c.branch_id, a.name_ar AS academy_name
FROM sa_academy_contracts c
LEFT JOIN sa_academies a ON a.id = c.academy_id
WHERE c.fixed_monthly_rent > 0
AND COALESCE(c.is_archived, 0) = 0
AND COALESCE(c.status, '') IN ('active', 'approved', 'terminated', 'expired')
ORDER BY c.id"
);
$today = new \DateTimeImmutable(date('Y-m-d'));
$items = [];
foreach ($rows as $r) {
$months = self::elapsedMonths(
(string) $r['start_date'],
$r['end_date'] ? (string) $r['end_date'] : null,
$today
);
if ($months < 1) {
continue;
}
$earned = bcmul(self::money((string) $r['fixed_monthly_rent']), (string) $months, self::SCALE);
$items[] = [
'document_id' => (int) $r['id'],
'counterparty_name' => $r['academy_name'] ?? null,
'amount' => $earned,
'document_number' => $r['contract_number'] ?? null,
'due_date' => $today->format('Y-m-d'),
'branch_id' => $r['branch_id'] ?? null,
'description_ar' => 'إيجار أكاديمية ' . ($r['contract_number'] ?? '')
. ' — ' . $months . ' شهر مستحق',
];
}
return self::post('academy:contract_rent', $items, [
'document_type' => 'academy_contract_rent',
'source_module' => 'academy_contracts',
'description_ar' => 'استحقاق إيجار الأكاديميات',
'reference_type' => 'academy_rent_accrual',
]);
}
// ────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────
// Releasing what has since been paid, or was never owed
// ────────────────────────────────────────────────────────────────────
/**
* How to tell, per document type, that an obligation is over.
*
* `settled` means the money arrived — collection has already booked the
* revenue, so the accrual must come off or the income counts twice.
* `cancelled` means it was never owed — a cancelled booking, an exempted
* subscription — and the accrual is reversed instead.
*
* Academy rent and deposits are absent on purpose: nothing in the product
* records their collection yet, so there is no signal to act on and the
* accrual correctly stays open until someone raises a receipt.
*/
private const CLOSERS = [
'subscription' => [
'stream' => 'subscription:annual_accrual',
'settled' => "SELECT id FROM subscriptions WHERE status = 'paid'",
'cancelled' => "SELECT id FROM subscriptions WHERE exempted_by IS NOT NULL AND status <> 'paid'",
],
'subscription_dev_fee' => [
'stream' => 'payment:development_fee',
'settled' => "SELECT id FROM subscriptions WHERE status = 'paid'",
'cancelled' => "SELECT id FROM subscriptions WHERE exempted_by IS NOT NULL AND status <> 'paid'",
],
'subscription_fine' => [
'stream' => 'subscription:late_fee',
'settled' => "SELECT id FROM subscriptions WHERE status = 'paid'",
'cancelled' => "SELECT id FROM subscriptions WHERE fine_amount = 0 OR exempted_by IS NOT NULL",
],
'sa_booking' => [
'stream' => 'sa:hourly_booking',
'settled' => "SELECT id FROM sa_bookings WHERE payment_status = 'paid'",
'cancelled' => "SELECT id FROM sa_bookings WHERE status IN ('cancelled','postponed')",
],
'sa_subscription' => [
'stream' => 'sa:monthly_subscription',
'settled' => "SELECT id FROM sa_subscriptions WHERE payment_status = 'paid'",
'cancelled' => "SELECT id FROM sa_subscriptions WHERE exempted_by IS NOT NULL AND payment_status <> 'paid'",
],
'sa_locker_rental' => [
'stream' => 'sa:locker_rental',
'settled' => "SELECT id FROM sa_locker_rentals WHERE payment_status = 'paid'",
'cancelled' => "SELECT id FROM sa_locker_rentals WHERE is_archived = 1",
],
'reservation' => [
'stream' => 'facility:reservation',
'settled' => "SELECT id FROM reservations WHERE payment_id IS NOT NULL",
'cancelled' => "SELECT id FROM reservations WHERE status IN ('cancelled','no_show') OR is_archived = 1",
],
'rental_invoice' => [
'stream' => 'rental:monthly_invoice',
'settled' => "SELECT id FROM rental_invoices WHERE status = 'paid'",
'cancelled' => "SELECT id FROM rental_invoices WHERE status = 'cancelled'",
],
'tournament_participant' => [
'stream' => 'tournament:registration_fee',
'settled' => "SELECT id FROM tournament_participants WHERE payment_id IS NOT NULL",
'cancelled' => "SELECT id FROM tournament_participants WHERE status IN ('withdrawn','cancelled','rejected')",
],
];
/**
* Close every accrual whose document has been paid or cancelled.
*
* Run after the accrual pass, not before: a document that was raised and
* paid between two runs must be accrued and released in the same night,
* otherwise its revenue is booked by collection with no accrual to match and
* the receivable never appears at all.
*/
public static function releases(): array
{
$db = App::getInstance()->db();
$out = [];
foreach (self::CLOSERS as $documentType => $spec) {
try {
$open = $db->select(
"SELECT document_id, accrued_amount, member_id
FROM posting_accruals
WHERE document_type = ? AND status = 'open' AND accrued_amount > 0",
[$documentType]
);
if (!$open) {
$out[$documentType] = self::result(0, 0, '0.00');
continue;
}
$openIds = array_map(static fn(array $r): int => (int) $r['document_id'], $open);
$byId = [];
foreach ($open as $r) {
$byId[(int) $r['document_id']] = (string) $r['accrued_amount'];
}
$settled = self::idsIn($spec['settled'], $openIds);
$cancelled = array_diff(self::idsIn($spec['cancelled'], $openIds), $settled);
// Paid — release against revenue.
$items = [];
foreach ($settled as $id) {
$items[] = ['document_id' => $id, 'amount' => $byId[$id]];
}
$released = ['count' => 0, 'total' => '0.00', 'journal_entry_id' => null, 'error' => null];
if ($items) {
$released = AccrualService::release($spec['stream'], $items, [
'document_type' => $documentType,
'source_module' => 'accounting',
'entry_date' => date('Y-m-d'),
'reference_type' => $documentType . '_release',
'description_ar' => 'إقفال استحقاق بعد التحصيل — ' . $documentType,
]);
}
// Never owed — reverse instead, so the accrual and its reversal
// both stay visible rather than the claim quietly vanishing.
$reversedCount = 0;
if ($cancelled) {
$items = [];
foreach ($cancelled as $id) {
$items[] = ['document_id' => $id, 'amount' => $byId[$id]];
}
$rev = AccrualService::release($spec['stream'], $items, [
'document_type' => $documentType,
'source_module' => 'accounting',
'entry_date' => date('Y-m-d'),
'reference_type' => $documentType . '_cancel',
'description_ar' => 'إلغاء استحقاق — المطالبة اتلغت أو اتعُفي منها',
]);
$reversedCount = $rev['count'];
foreach ($cancelled as $id) {
\App\Modules\Accounting\Services\SubledgerService::closeAccrual(
$documentType, $id, 'cancelled', 'المطالبة اتلغت'
);
\App\Modules\Accounting\Services\SubledgerService::closeReceivable(
$documentType, $id, 'cancelled', 'المطالبة اتلغت'
);
}
}
$out[$documentType] = self::result(
count($open),
$released['count'] + $reversedCount,
$released['total'],
$released['journal_entry_id'],
$released['error']
);
} catch (\Throwable $e) {
Logger::error('Accrual release failed', ['type' => $documentType, 'error' => $e->getMessage()]);
$out[$documentType] = self::result(0, 0, '0.00', null, $e->getMessage());
}
}
return $out;
}
/** Ids matching a closer condition, restricted to the accruals we hold open. */
private static function idsIn(string $sql, array $openIds): array
{
if (!$openIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($openIds), '?'));
$rows = App::getInstance()->db()->select(
"SELECT t.id FROM ({$sql}) t WHERE t.id IN ({$placeholders})",
$openIds
);
return array_map(static fn(array $r): int => (int) $r['id'], $rows);
}
/**
* Obligations this scanner deliberately refuses to book, and why.
*
* Each of these has money implied somewhere in the product but no amount and
* no counterparty the ledger could stand behind. Posting a guess would be
* worse than the gap: a wrong number in the accounts is harder to find than
* a missing one, and it would look settled.
*/
public static function unbookable(): array
{
$db = App::getInstance()->db();
$count = static function (string $sql) use ($db): int {
try {
return (int) ($db->selectOne($sql)['n'] ?? 0);
} catch (\Throwable) {
return 0;
}
};
return [
[
'stream' => 'sa:pool_zone_booking',
'label' => 'حجوزات مناطق حمام السباحة',
'rows' => $count("SELECT COUNT(*) n FROM sa_pool_zone_bookings"),
'why' => 'الجدول فيه سعر التذكرة وعدد الحاضرين، بس مفيش سجل لمين دخل ولا هل دفع. '
. 'الاستحقاق هنا هيبقى تقدير مش مطالبة، فمش هينزل الدفاتر.',
'needs' => 'سجل دخول لكل شخص (أو تذكرة) عشان يبقى فيه مطالبة حقيقية تتقيّد.',
],
[
'stream' => 'sa:player_card',
'label' => 'كارنيهات اللاعبين',
'rows' => $count("SELECT COUNT(*) n FROM sa_player_cards"),
'why' => 'الجدول مفيهوش عمود مبلغ أصلًا — الكارنيه بيتصدر من غير رسم مسجّل.',
'needs' => 'رسم إصدار/تجديد على الكارنيه، وبعدين الاستحقاق بيمشي لوحده.',
],
[
'stream' => 'facility:pool_booking',
'label' => 'حجز حمام السباحة',
'rows' => $count("SELECT COUNT(*) n FROM pool_bookings"),
'why' => 'الكود بيسجّل كل حجز بسعر صفر ثابت — مش مشكلة محاسبية، ده تسعير ناقص.',
'needs' => 'تسعيرة للحجز في الكود أو في دليل الخدمات.',
],
[
'stream' => 'facility:private_match',
'label' => 'حجوزات الماتشات الخاصة',
'rows' => $count("SELECT COUNT(*) n FROM private_match_bookings"),
'why' => 'المقدم بيتكتب في عمود deposit_paid من غير إيصال ولا دفعة، فمفيش مستند يتقيّد عليه.',
'needs' => 'تحصيل المقدم كدفعة عادية بإيصال.',
],
[
'stream' => 'academy:enrollment',
'label' => 'قيد اللاعبين في الأكاديميات',
'rows' => $count("SELECT COUNT(*) n FROM academy_enrollments"),
'why' => 'القيد نفسه مالوش رسوم — هو بوابة للفوترة الشهرية اللي بتيجي من اشتراكات النشاط.',
'needs' => 'لا شيء محاسبيًا — الإيراد بيتقيّد من اشتراك النشاط مش من القيد.',
],
];
}
/** What every runner has posted so far, for the screen. */
public static function status(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT a.stream_code, s.name_ar AS stream_name,
COUNT(*) AS claims,
SUM(a.accrued_amount) AS accrued,
SUM(CASE WHEN a.status = 'open' THEN a.accrued_amount ELSE 0 END) AS open_amount,
MIN(a.document_date) AS first_date,
MAX(a.updated_at) AS last_run
FROM posting_accruals a
LEFT JOIN revenue_streams s ON s.stream_code = a.stream_code
WHERE a.status <> 'reversed'
GROUP BY a.stream_code, s.name_ar
ORDER BY SUM(a.accrued_amount) DESC"
);
}
// ────────────────────────────────────────────────────────────────────
private static function post(string $streamCode, array $items, array $opts): array
{
if (!$items) {
return self::result(0, 0, '0.00');
}
$r = AccrualService::batch($streamCode, $items, $opts + ['entry_date' => date('Y-m-d')]);
return self::result(count($items), $r['count'], $r['total'], $r['journal_entry_id'], $r['error']);
}
private static function result(
int $scanned,
int $posted,
string $total,
?int $entryId = null,
?string $error = null
): array {
return [
'scanned' => $scanned,
'posted' => $posted,
'total' => $total,
'journal_entry_id' => $entryId,
'error' => $error,
];
}
/**
* A booking's `booker_id` points at different tables depending on
* `booker_type`. Only a member gives us a member id — reading it for a
* player or an institution would attach the debt to whichever member happens
* to share that row number.
*/
private static function memberIdIfMember(string $bookerType, mixed $bookerId): ?int
{
if ($bookerType !== 'member' || (int) $bookerId <= 0) {
return null;
}
$exists = App::getInstance()->db()->selectOne(
"SELECT id FROM members WHERE id = ?",
[(int) $bookerId]
);
return $exists ? (int) $bookerId : null;
}
/** Whole months elapsed on a contract, capped at its end. */
private static function elapsedMonths(string $start, ?string $end, \DateTimeImmutable $today): int
{
if ($start === '' || strtotime($start) === false) {
return 0;
}
$from = new \DateTimeImmutable(substr($start, 0, 10));
$to = $today;
if ($end !== null && strtotime($end) !== false) {
$endDate = new \DateTimeImmutable(substr($end, 0, 10));
if ($endDate < $to) {
$to = $endDate;
}
}
if ($to <= $from) {
return 0;
}
$diff = $from->diff($to);
return ($diff->y * 12) + $diff->m;
}
/** Subscriptions fall due at the start of the financial year they cover. */
private static function financialYearDue(string $financialYear): string
{
$year = (int) (explode('/', $financialYear)[0] ?? 0);
return $year > 2000 ? sprintf('%d-07-01', $year) : date('Y-m-d');
}
private static function money(string $v): string
{
return number_format((float) $v, 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\Models\JournalEntry;
use App\Modules\Accounting\Services\SubledgerService;
/**
* Telling the ledger about money that is owed but not yet moved.
*
* Most of what was unwired in this system shares one shape: an obligation comes
* into existence — a subscription is generated, a court is booked, a loan is
* paid out — and nothing tells accounting. The money is real, the club's
* position has changed, and the general ledger finds out only if and when
* somebody happens to pay. Until then the club under-reports what it is owed
* and over-reports its result, because the income lands in the wrong period.
*
* The fix is an accrual, and accruals come in two shapes:
*
* single one obligation, one entry — a booking, a loan, an invoice.
* batch one run, one entry, many claims — the annual subscription
* generation raises 577 debts at once, and that is one accounting
* event, not 577. Posting an entry per member would bury the journal
* and take minutes to run for no gain: the per-member detail belongs
* in the receivable subledger, which is where every report reads it.
*
* Both are safe to re-run. A single is keyed on its journal reference, a batch
* on the subledger, so a cron that fires twice or a run that died half way
* posts exactly what is missing.
*/
final class AccrualService
{
private const SCALE = 2;
/**
* Accrue one obligation.
*
* @param array $ctx amount, entry_date, member_id, reference_type,
* reference_id, source_module, description_ar, and — when
* the obligation is a member debt — document_type and
* due_date so it lands in the receivable subledger too.
*
* @return array{posted:bool, journal_entry_id:?int, skipped:bool, error:?string}
*/
public static function single(string $streamCode, array $ctx, string $stage = 'accrual'): array
{
$amount = self::money((string) ($ctx['amount'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
return ['posted' => false, 'journal_entry_id' => null, 'skipped' => true, 'error' => null];
}
$refType = (string) ($ctx['reference_type'] ?? '');
$refId = (int) ($ctx['reference_id'] ?? 0);
// Already told the ledger about this one.
if ($refType !== '' && $refId > 0) {
$existing = JournalEntry::findByReference($refType, $refId);
if ($existing) {
return [
'posted' => false,
'journal_entry_id' => (int) $existing->id,
'skipped' => true,
'error' => null,
];
}
}
$routed = PostingRouter::attempt($streamCode, $stage, $ctx + ['amount' => $amount]);
if (!$routed['handled']) {
// No rule configured. Not an error — it means finance has not mapped
// this stream yet, and the mapping screen already reports it.
return ['posted' => false, 'journal_entry_id' => null, 'skipped' => true, 'error' => null];
}
if ($routed['journal_entry_id'] === null) {
Logger::error('Accrual failed', ['stream' => $streamCode, 'ref' => $refType . '#' . $refId]);
return ['posted' => false, 'journal_entry_id' => null, 'skipped' => false, 'error' => 'فشل قيد الاستحقاق'];
}
$entryId = $routed['journal_entry_id'];
if (!empty($ctx['document_type'])) {
$documentId = (int) ($ctx['document_id'] ?? $refId);
$dueDate = $ctx['due_date'] ?? date('Y-m-d', strtotime('+30 days'));
SubledgerService::recordAccrual([
'stream_code' => $streamCode,
'document_type' => (string) $ctx['document_type'],
'document_id' => $documentId,
'document_number' => $ctx['document_number'] ?? null,
'accrued_amount' => $amount,
'member_id' => $ctx['member_id'] ?? null,
'counterparty_name' => $ctx['counterparty_name'] ?? null,
'journal_entry_id' => $entryId,
'document_date' => $ctx['entry_date'] ?? date('Y-m-d'),
'due_date' => $dueDate,
'branch_id' => $ctx['branch_id'] ?? null,
]);
if (!empty($ctx['member_id'])) {
SubledgerService::upsertReceivable([
'member_id' => (int) $ctx['member_id'],
'document_type' => (string) $ctx['document_type'],
'document_id' => $documentId,
'document_number' => $ctx['document_number'] ?? null,
'document_date' => $ctx['entry_date'] ?? date('Y-m-d'),
'due_date' => $dueDate,
'description_ar' => (string) ($ctx['description_ar'] ?? $streamCode),
'total_amount' => $amount,
'journal_entry_id' => $entryId,
'branch_id' => $ctx['branch_id'] ?? null,
]);
}
}
return ['posted' => true, 'journal_entry_id' => $entryId, 'skipped' => false, 'error' => null];
}
/**
* Accrue a run of obligations as one entry plus many claims.
*
* @param array $items each: [document_id, member_id, amount, description_ar,
* due_date?, document_number?, branch_id?]
* @param array $opts document_type, entry_date, source_module,
* description_ar, reference_type, reference_id
*
* @return array{posted:bool, journal_entry_id:?int, count:int, total:string, error:?string}
*/
public static function batch(string $streamCode, array $items, array $opts): array
{
$documentType = (string) ($opts['document_type'] ?? '');
// Only the part nobody has been told about. A fine that grew from 50 to
// 75 contributes 25 here, and its claim is then restated to 75.
$deltas = [];
$total = '0.00';
foreach ($items as $item) {
$documentId = (int) ($item['document_id'] ?? 0);
$amount = self::money((string) ($item['amount'] ?? '0'));
if ($documentId <= 0 || bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$already = $documentType !== ''
? SubledgerService::accruedAmount($documentType, $documentId)
: '0.00';
$delta = bcsub($amount, $already, self::SCALE);
if (bccomp($delta, '0.00', self::SCALE) <= 0) {
continue; // already accrued, or the obligation shrank
}
$deltas[] = $item + ['delta' => $delta, 'amount' => $amount];
$total = bcadd($total, $delta, self::SCALE);
}
if (!$deltas || bccomp($total, '0.00', self::SCALE) <= 0) {
return ['posted' => false, 'journal_entry_id' => null, 'count' => 0, 'total' => '0.00', 'error' => null];
}
$routed = PostingRouter::attempt($streamCode, 'accrual', [
'amount' => $total,
'entry_date' => $opts['entry_date'] ?? date('Y-m-d'),
'reference_type' => $opts['reference_type'] ?? null,
'reference_id' => $opts['reference_id'] ?? null,
'reference_number' => $opts['reference_number'] ?? null,
'source_module' => $opts['source_module'] ?? null,
'branch_id' => $opts['branch_id'] ?? null,
'description_ar' => $opts['description_ar'] ?? null,
]);
if (!$routed['handled']) {
return ['posted' => false, 'journal_entry_id' => null, 'count' => 0, 'total' => '0.00', 'error' => null];
}
if ($routed['journal_entry_id'] === null) {
Logger::error('Batch accrual failed', ['stream' => $streamCode, 'total' => $total]);
return [
'posted' => false, 'journal_entry_id' => null, 'count' => 0,
'total' => '0.00', 'error' => 'فشل قيد الاستحقاق المجمّع',
];
}
$entryId = $routed['journal_entry_id'];
// The claims go in after the entry, and each carries the entry that
// raised it. If this loop dies half way the next run picks up exactly
// the ones that never got a row — that is what posting_accruals is for.
$count = 0;
foreach ($deltas as $item) {
$documentId = (int) $item['document_id'];
$dueDate = $item['due_date'] ?? ($opts['due_date'] ?? date('Y-m-d', strtotime('+30 days')));
$desc = (string) ($item['description_ar'] ?? $opts['description_ar'] ?? $streamCode);
SubledgerService::recordAccrual([
'stream_code' => $streamCode,
'document_type' => $documentType,
'document_id' => $documentId,
'document_number' => $item['document_number'] ?? null,
'accrued_amount' => $item['amount'],
'member_id' => $item['member_id'] ?? null,
'counterparty_name' => $item['counterparty_name'] ?? null,
'journal_entry_id' => $entryId,
'document_date' => $opts['entry_date'] ?? date('Y-m-d'),
'due_date' => $dueDate,
'branch_id' => $item['branch_id'] ?? ($opts['branch_id'] ?? null),
]);
// The member-facing view. Non-members legitimately have no row —
// accounts_receivable.member_id is NOT NULL — and their obligation
// is tracked in posting_accruals above.
if (!empty($item['member_id'])) {
SubledgerService::upsertReceivable([
'member_id' => (int) $item['member_id'],
'document_type' => $documentType,
'document_id' => $documentId,
'document_number' => $item['document_number'] ?? null,
'document_date' => $opts['entry_date'] ?? date('Y-m-d'),
'due_date' => $dueDate,
'description_ar' => $desc,
'total_amount' => $item['amount'],
'journal_entry_id' => $entryId,
'branch_id' => $item['branch_id'] ?? ($opts['branch_id'] ?? null),
]);
}
$count++;
}
Logger::info('Batch accrual posted', [
'stream' => $streamCode, 'entry' => $entryId, 'claims' => $count, 'total' => $total,
]);
return [
'posted' => true, 'journal_entry_id' => $entryId,
'count' => $count, 'total' => $total, 'error' => null,
];
}
/**
* Release accruals whose documents have since been paid.
*
* This is the half that makes accruing safe. Collection in this system posts
* Dr Cash / Cr Revenue directly — `onSubscriptionPaid` says so in as many
* words — so an accrual that is left standing when the money arrives leaves
* the income booked twice and the receivable never clearing:
*
* accrual Dr Receivable 100 Cr Revenue 100
* collection Dr Cash 100 Cr Revenue 100 ← revenue is now 200
* release Dr Revenue 100 Cr Receivable 100 ← back to 100, AR nil
*
* The release is the exact mirror of the accrual — built by re-planning the
* same rule and flipping every line — so a split accrual unwinds along
* exactly the lines it was booked on, and a rule someone has since edited
* cannot leave a stub behind on an account the accrual never touched.
*
* Deliberately driven by the accrual ledger rather than by intercepting the
* payment path: it cannot fire for money that was never accrued, and it is
* idempotent, so a document that is paid twice releases once.
*
* @param array $items each: [document_id, amount]
* @return array{posted:bool, journal_entry_id:?int, count:int, total:string, error:?string}
*/
public static function release(string $streamCode, array $items, array $opts): array
{
$documentType = (string) ($opts['document_type'] ?? '');
$total = '0.00';
$keep = [];
foreach ($items as $item) {
$amount = self::money((string) ($item['amount'] ?? '0'));
if (bccomp($amount, '0.00', self::SCALE) <= 0) {
continue;
}
$keep[] = $item;
$total = bcadd($total, $amount, self::SCALE);
}
if (!$keep || bccomp($total, '0.00', self::SCALE) <= 0) {
return ['posted' => false, 'journal_entry_id' => null, 'count' => 0, 'total' => '0.00', 'error' => null];
}
$entryDate = $opts['entry_date'] ?? date('Y-m-d');
$plan = RevenuePostingEngine::plan($streamCode, [
'amount' => $total,
'stage' => 'accrual',
'entry_date' => $entryDate,
'branch_id' => $opts['branch_id'] ?? null,
]);
if (!$plan['resolved'] || !empty($plan['errors']) || count($plan['lines']) < 2) {
return [
'posted' => false, 'journal_entry_id' => null, 'count' => 0, 'total' => '0.00',
'error' => $plan['error'] ?? implode(' | ', $plan['errors'] ?? []) ?: 'تعذّر بناء قيد إقفال الاستحقاق',
];
}
$description = (string) ($opts['description_ar'] ?? ('إقفال استحقاق — ' . $streamCode));
$lines = [];
foreach ($plan['lines'] as $l) {
$lines[] = [
'account_id' => $l['account_id'],
'debit' => $l['credit'],
'credit' => $l['debit'],
'description_ar' => $description,
'cost_center_id' => $l['cost_center_id'] ?? null,
'branch_id' => $l['branch_id'] ?? null,
];
}
$result = \App\Modules\Accounting\Services\JournalService::createEntry([
'entry_date' => $entryDate,
'description_ar' => $description,
'reference_type' => $opts['reference_type'] ?? null,
'reference_id' => $opts['reference_id'] ?? null,
'source_module' => $opts['source_module'] ?? null,
'branch_id' => $opts['branch_id'] ?? null,
'is_auto_generated' => 1,
'notes' => 'إقفال استحقاق بعد التحصيل — التحصيل نفسه سجّل الإيراد، '
. 'والقيد ده بيشيل الاستحقاق عشان الإيراد ما يتعدّش مرتين.',
], $lines, true);
if (empty($result['success'])) {
Logger::error('Accrual release failed', ['stream' => $streamCode, 'error' => $result['error'] ?? null]);
return [
'posted' => false, 'journal_entry_id' => null, 'count' => 0, 'total' => '0.00',
'error' => $result['error'] ?? 'فشل قيد إقفال الاستحقاق',
];
}
$entryId = (int) $result['journal_entry_id'];
$count = 0;
foreach ($keep as $item) {
$documentId = (int) $item['document_id'];
SubledgerService::closeAccrual($documentType, $documentId, 'settled', 'اتحصّل — الاستحقاق اتقفل');
SubledgerService::settleReceivable($documentType, $documentId, (string) $item['amount'], $entryId);
$count++;
}
return ['posted' => true, 'journal_entry_id' => $entryId, 'count' => $count, 'total' => $total, 'error' => null];
}
/**
* Reverse an accrual that turned out not to be owed — a fine waived, a
* booking cancelled before it was billed.
*
* A reversal, not a deletion. The club did believe it was owed the money on
* the day it said so, and the accounts have to keep saying that.
*/
public static function reverse(string $refType, int $refId, string $reason, ?string $documentType = null): bool
{
$entry = JournalEntry::findByReference($refType, $refId);
if (!$entry || !$entry->isPosted()) {
return false;
}
$result = \App\Modules\Accounting\Services\JournalService::reverseEntry((int) $entry->id, $reason);
if (empty($result['success'])) {
Logger::error('Accrual reversal failed', [
'ref' => $refType . '#' . $refId,
'error' => $result['error'] ?? null,
]);
return false;
}
if ($documentType !== null) {
SubledgerService::closeAccrual($documentType, $refId, 'reversed', $reason);
SubledgerService::closeReceivable($documentType, $refId, 'cancelled', $reason);
}
return true;
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
......@@ -7,6 +7,7 @@ use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\AccountCodes;
use App\Modules\Accounting\Services\JournalService;
use App\Modules\Accounting\Services\TreasuryAccountService;
/**
* Account determination for the full accounting cycle.
......@@ -567,15 +568,36 @@ final class RevenuePostingEngine
// auto_treasury — the account the money actually landed in.
//
// A configurable pointer per payment method comes first, because the legacy
// constants get this wrong in a way that matters: a cheque is not money at
// the bank. Taking a post-dated cheque creates a note receivable, and it only
// becomes bank cash when the bank collects it — which is what the instrument
// lifecycle then posts. Mapping `treasury:method_check` to أوراق قبض fixes
// that from the screen instead of in code.
// Physical cash first. If the document names a safe, the money is in THAT
// safe and nowhere else, so its own account is the only correct debit. This
// outranks the per-method pointer deliberately: the pointer answers "which
// account does cash go to", one answer for the whole club, and that is the
// wrong question the moment there is more than one till. Resolving by
// method here is what left the settlement crediting an account the
// collection had never debited, so the safe never cleared.
//
// Non-cash never enters a safe — a visa slip is bank money and a cheque is a
// receivable — so those keep resolving by method.
$method = $ctx['payment_method'] ?? 'cash';
$treasuryId = isset($ctx['treasury_id']) && $ctx['treasury_id'] ? (int) $ctx['treasury_id'] : null;
if ($method === 'cash' && $treasuryId !== null) {
$safe = TreasuryAccountService::resolve($treasuryId);
if ($safe['account_id'] !== null) {
return $safe['account_id'];
}
// Falling through to a club-wide cash account would put this till's
// takings somewhere the settlement will never look for them.
$errors[] = $safe['error'] ?? 'تعذّر تحديد حساب الخزنة';
return null;
}
// A configurable pointer per method, because the legacy constants get this
// wrong in a way that matters: a cheque is not money at the bank. Taking a
// post-dated cheque creates a note receivable, and it only becomes bank cash
// when the bank collects it — which is what the instrument lifecycle then
// posts. Mapping `treasury:method_check` to أوراق قبض fixes that from the
// screen instead of in code.
$safeMethod = preg_match('/^[a-z_]{1,30}$/', $method) === 1 ? $method : 'cash';
$pointer = PostingRouter::accountFor('treasury:method_' . $safeMethod, null, 'collection');
if ($pointer !== null) {
......
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* The member-level receivable ledger.
*
* The general ledger knows the club is owed 199,414. It does not know that
* 492 of it is owed by member 1183 and has been outstanding since July. That
* lives here, keyed by (document_type, document_id) so a subscription, a fine
* and an installment can all raise a claim against the same member without
* colliding.
*
* It is also the idempotency key for accruals. An obligation that already has a
* row here has already been posted, so a generation run that is repeated — or
* that died half way through — accrues exactly what is missing and nothing else.
* That property is what lets the accrual runners be safe to re-run at any time,
* which in turn is what lets them be cron jobs rather than a once-a-year ritual
* nobody dares repeat.
*/
final class SubledgerService
{
private const SCALE = 2;
/**
* Raise or update a claim against a member.
*
* `total_amount` is restated rather than added to, because the caller knows
* the true total — a late fine that grows from 50 to 75 is one claim worth
* 75, not two claims. The paid amount is never touched here; collection owns
* that.
*/
public static function upsertReceivable(array $r): int
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$amount = self::money((string) ($r['total_amount'] ?? '0'));
$existing = $db->selectOne(
"SELECT id, paid_amount FROM accounts_receivable WHERE document_type = ? AND document_id = ?",
[$r['document_type'], (int) $r['document_id']]
);
if ($existing) {
$paid = self::money((string) ($existing['paid_amount'] ?? '0'));
$balance = bcsub($amount, $paid, self::SCALE);
$db->update('accounts_receivable', [
'total_amount' => $amount,
'balance' => $balance,
'status' => self::status($amount, $paid, (string) $r['due_date']),
'journal_entry_id' => $r['journal_entry_id'] ?? null,
'description_ar' => $r['description_ar'],
'updated_at' => $now,
], '`id` = ?', [(int) $existing['id']]);
return (int) $existing['id'];
}
return $db->insert('accounts_receivable', [
'member_id' => (int) $r['member_id'],
'document_type' => $r['document_type'],
'document_id' => (int) $r['document_id'],
'document_number' => $r['document_number'] ?? null,
'document_date' => $r['document_date'],
'due_date' => $r['due_date'],
'description_ar' => $r['description_ar'],
'total_amount' => $amount,
'paid_amount' => '0.00',
'balance' => $amount,
'status' => self::status($amount, '0.00', (string) $r['due_date']),
'journal_entry_id' => $r['journal_entry_id'] ?? null,
'branch_id' => $r['branch_id'] ?? null,
'notes' => $r['notes'] ?? null,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* How much of this obligation the ledger has already been told about.
*
* Read from posting_accruals, not from here. An obligation owed by an
* institution or a non-member player has no row in this table — member_id is
* NOT NULL — so using it as the idempotency key would re-post those every
* single run.
*
* Accruals that grow — a late fine recalculated every night — must post only
* the difference. Posting the full amount again books the same debt twice
* and inflates both the receivable and the income.
*/
public static function accruedAmount(string $documentType, int $documentId): string
{
$row = App::getInstance()->db()->selectOne(
"SELECT accrued_amount FROM posting_accruals
WHERE document_type = ? AND document_id = ? AND status <> 'reversed'",
[$documentType, $documentId]
);
return $row ? self::money((string) $row['accrued_amount']) : '0.00';
}
/** Every amount already accrued for a document type, keyed by document id. */
public static function accruedMap(string $documentType): array
{
$rows = App::getInstance()->db()->select(
"SELECT document_id, accrued_amount FROM posting_accruals
WHERE document_type = ? AND status <> 'reversed'",
[$documentType]
);
$out = [];
foreach ($rows as $r) {
$out[(int) $r['document_id']] = self::money((string) $r['accrued_amount']);
}
return $out;
}
/** Record, or restate, what the ledger now believes is owed on a document. */
public static function recordAccrual(array $a): void
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$amount = self::money((string) ($a['accrued_amount'] ?? '0'));
$existing = $db->selectOne(
"SELECT id FROM posting_accruals WHERE document_type = ? AND document_id = ?",
[$a['document_type'], (int) $a['document_id']]
);
if ($existing) {
$db->update('posting_accruals', [
'accrued_amount' => $amount,
'journal_entry_id' => $a['journal_entry_id'] ?? null,
'status' => 'open',
'updated_at' => $now,
], '`id` = ?', [(int) $existing['id']]);
return;
}
$db->insert('posting_accruals', [
'stream_code' => $a['stream_code'],
'document_type' => $a['document_type'],
'document_id' => (int) $a['document_id'],
'document_number' => $a['document_number'] ?? null,
'accrued_amount' => $amount,
'settled_amount' => '0.00',
'member_id' => !empty($a['member_id']) ? (int) $a['member_id'] : null,
'counterparty_name' => $a['counterparty_name'] ?? null,
'journal_entry_id' => $a['journal_entry_id'] ?? null,
'document_date' => $a['document_date'] ?? date('Y-m-d'),
'due_date' => $a['due_date'] ?? null,
'branch_id' => $a['branch_id'] ?? null,
'status' => 'open',
'first_accrued_at' => $now,
'updated_at' => $now,
]);
}
/** Mark an accrual closed — collected, written off, or reversed. */
public static function closeAccrual(string $documentType, int $documentId, string $status, ?string $note = null): void
{
if (!\in_array($status, ['settled', 'reversed', 'cancelled'], true)) {
return;
}
App::getInstance()->db()->update('posting_accruals', [
'status' => $status,
'notes' => $note,
'updated_at' => date('Y-m-d H:i:s'),
], 'document_type = ? AND document_id = ?', [$documentType, $documentId]);
}
/** Record a collection against a claim. */
public static function settleReceivable(string $documentType, int $documentId, string $paid, ?int $paymentEntryId = null): void
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT id, total_amount FROM accounts_receivable WHERE document_type = ? AND document_id = ?",
[$documentType, $documentId]
);
if (!$row) {
return;
}
$total = self::money((string) $row['total_amount']);
$paid = self::money($paid);
$balance = bcsub($total, $paid, self::SCALE);
$db->update('accounts_receivable', [
'paid_amount' => $paid,
'balance' => $balance,
'status' => bccomp($balance, '0.00', self::SCALE) <= 0 ? 'paid'
: (bccomp($paid, '0.00', self::SCALE) > 0 ? 'partial' : 'pending'),
'payment_entry_id' => $paymentEntryId,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $row['id']]);
}
/** Close a claim that will never be collected, or that was cancelled. */
public static function closeReceivable(string $documentType, int $documentId, string $status, ?string $note = null): void
{
if (!\in_array($status, ['written_off', 'cancelled'], true)) {
return;
}
App::getInstance()->db()->update('accounts_receivable', [
'balance' => '0.00',
'status' => $status,
'notes' => $note,
'updated_at' => date('Y-m-d H:i:s'),
], 'document_type = ? AND document_id = ?', [$documentType, $documentId]);
}
private static function status(string $total, string $paid, string $dueDate): string
{
if (bccomp(bcsub($total, $paid, self::SCALE), '0.00', self::SCALE) <= 0) {
return 'paid';
}
if (bccomp($paid, '0.00', self::SCALE) > 0) {
return 'partial';
}
return $dueDate < date('Y-m-d') ? 'overdue' : 'pending';
}
private static function money(string $v): string
{
return number_format((float) $v, self::SCALE, '.', '');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* The one answer to "which account holds the cash in this safe".
*
* It exists so that there is exactly one answer. The bug that motivated this
* package was two of them: the collection resolved the account by payment
* method and the settlement resolved it by a global pointer, so the settlement
* credited an account the collection had never debited and the safe could never
* clear. Both paths now call this.
*
* It also owns the correction of what the old wiring left behind. The sub-safes
* were pointed at the foreign-currency cash boxes, so EGP takings were booked
* into الصندوق بالدولار and الصندوق باليورو. Those entries are posted history and
* are not edited — the fix is a reclassification entry that finance previews and
* posts deliberately.
*/
final class TreasuryAccountService
{
private const SCALE = 2;
/** Currency boxes the safes used to be aimed at, and must never be aimed at again. */
private const CURRENCY_BOXES = ['12060102', '12060103'];
/**
* @return array{account_id:?int, error:?string, name:?string}
*/
public static function resolve(int $treasuryId): array
{
if ($treasuryId <= 0) {
return ['account_id' => null, 'error' => 'المستند مش محدد فيه خزنة', 'name' => null];
}
$db = App::getInstance()->db();
$safe = $db->selectOne(
"SELECT id, code, name_ar, gl_account_id, account_code FROM treasuries WHERE id = ?",
[$treasuryId]
);
if (!$safe) {
return ['account_id' => null, 'error' => 'الخزنة رقم ' . $treasuryId . ' مش موجودة', 'name' => null];
}
$accountId = (int) ($safe['gl_account_id'] ?? 0);
// account_code is the pre-migration fallback. Kept so a half-migrated
// environment still posts rather than blocking the cashiers.
if ($accountId <= 0 && !empty($safe['account_code'])) {
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$safe['account_code']]
);
$accountId = (int) ($row['id'] ?? 0);
}
if ($accountId <= 0) {
return [
'account_id' => null,
'error' => 'خزنة «' . $safe['name_ar'] . '» ملهاش حساب في دليل الحسابات',
'name' => $safe['name_ar'],
];
}
$acc = $db->selectOne(
"SELECT id, account_code, name_ar, is_header, is_active, is_archived
FROM chart_of_accounts WHERE id = ?",
[$accountId]
);
if (!$acc) {
return ['account_id' => null, 'error' => 'حساب الخزنة مش موجود', 'name' => $safe['name_ar']];
}
if ((int) $acc['is_header'] === 1) {
return [
'account_id' => null,
'error' => 'حساب خزنة «' . $safe['name_ar'] . '» رئيسي ومش بيقبل ترحيل',
'name' => $safe['name_ar'],
];
}
if ((int) $acc['is_active'] === 0 || (int) $acc['is_archived'] === 1) {
return [
'account_id' => null,
'error' => 'حساب خزنة «' . $safe['name_ar'] . '» موقوف أو مؤرشف',
'name' => $safe['name_ar'],
];
}
return ['account_id' => $accountId, 'error' => null, 'name' => $safe['name_ar']];
}
public static function accountFor(int $treasuryId): ?int
{
return self::resolve($treasuryId)['account_id'];
}
// ────────────────────────────────────────────────────────────────────
// Correcting what the old wiring booked
// ────────────────────────────────────────────────────────────────────
/**
* Cash that a safe collected but that was posted somewhere else, less
* whatever has already been corrected.
*
* Matched precisely rather than by account balance: only lines whose entry
* references a payment, and whose payment names the safe, and whose account
* is not the safe's account today. Genuine foreign-currency cash in those
* boxes is left alone — it has no payment behind it naming a safe.
*
* The `already_moved` leg is what stops this being a trap. The correction
* does not rewrite the original payment entries, so they keep matching for
* ever; without netting off prior corrections, pressing the button twice
* would move the money twice and leave the currency boxes negative. Prior
* corrections are found by their line-level reference, which also means a
* reversed correction correctly reappears here as work still to do.
*
* @return array{rows:array, total:string, as_of:string}
*/
public static function previewReclassification(): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT t.id AS treasury_id,
t.name_ar AS treasury_name,
t.gl_account_id AS correct_account_id,
correct.account_code AS correct_code,
correct.name_ar AS correct_name,
l.account_id AS wrong_account_id,
wrong.account_code AS wrong_code,
wrong.name_ar AS wrong_name,
COUNT(*) AS line_count,
SUM(l.debit) AS total_debit,
SUM(l.credit) AS total_credit,
COALESCE(moved.amount, 0) AS already_moved,
MIN(e.entry_date) AS first_date,
MAX(e.entry_date) AS last_date
FROM journal_entry_lines l
JOIN journal_entries e ON e.id = l.journal_entry_id
AND e.status IN ('posted','reversed')
JOIN payments p ON p.id = e.reference_id AND e.reference_type = 'payment'
JOIN treasuries t ON t.id = p.treasury_id
JOIN chart_of_accounts wrong ON wrong.id = l.account_id
LEFT JOIN chart_of_accounts correct ON correct.id = t.gl_account_id
LEFT JOIN (
SELECT rl.account_id AS wrong_account_id,
rl.reference_id AS treasury_id,
SUM(rl.credit - rl.debit) AS amount
FROM journal_entry_lines rl
JOIN journal_entries re ON re.id = rl.journal_entry_id
AND re.status IN ('posted','reversed')
WHERE rl.reference_type = 'treasury_reclassification'
GROUP BY rl.account_id, rl.reference_id
) moved ON moved.wrong_account_id = l.account_id AND moved.treasury_id = t.id
WHERE t.gl_account_id IS NOT NULL
AND l.account_id <> t.gl_account_id
AND wrong.account_code IN ('" . implode("','", self::CURRENCY_BOXES) . "')
GROUP BY t.id, t.name_ar, t.gl_account_id, correct.account_code, correct.name_ar,
l.account_id, wrong.account_code, wrong.name_ar, moved.amount
HAVING SUM(l.debit) - SUM(l.credit) - COALESCE(moved.amount, 0) > 0.004
ORDER BY t.id"
);
$total = '0.00';
foreach ($rows as $i => $r) {
$gross = bcsub(
number_format((float) $r['total_debit'], self::SCALE, '.', ''),
number_format((float) $r['total_credit'], self::SCALE, '.', ''),
self::SCALE
);
$moved = number_format((float) $r['already_moved'], self::SCALE, '.', '');
$net = bcsub($gross, $moved, self::SCALE);
$rows[$i]['gross_amount'] = $gross;
$rows[$i]['already_moved'] = $moved;
$rows[$i]['net_amount'] = $net;
$total = bcadd($total, $net, self::SCALE);
}
return ['rows' => $rows, 'total' => $total, 'as_of' => date('Y-m-d')];
}
/**
* Post the correction as one dated journal entry.
*
* Dr the safe's own cash account
* Cr the currency box it was wrongly booked into
*
* Deliberately a normal, visible, reversible entry rather than an UPDATE on
* history. An auditor asking "why did the euro box drop by 909,502 on this
* date" gets an entry that says so, with its reason in the notes.
*
* @return array{success:bool, journal_entry_id:?int, error:?string, moved:string}
*/
public static function postReclassification(?string $entryDate = null, ?string $note = null): array
{
$preview = self::previewReclassification();
if (!$preview['rows']) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'مفيش حاجة محتاجة تصحيح', 'moved' => '0.00'];
}
$lines = [];
$moved = '0.00';
foreach ($preview['rows'] as $r) {
$net = (string) $r['net_amount'];
if (bccomp($net, '0.00', self::SCALE) <= 0) {
// A negative net would mean the safe's cash was credited out of the
// currency box more than into it. Not something to guess at.
continue;
}
if (empty($r['correct_account_id'])) {
return [
'success' => false, 'journal_entry_id' => null, 'moved' => '0.00',
'error' => 'خزنة «' . $r['treasury_name'] . '» لسه ملهاش حساب — شغّل الترحيلات الأول',
];
}
$desc = 'تصحيح تبويب: نقدية ' . $r['treasury_name'] . ' كانت متسجّلة في ' . $r['wrong_name'];
// Tagged at line level with the safe it belongs to. That tag is what
// previewReclassification() reads to know this pair is already done,
// so the button cannot be pressed twice into a double correction.
$tag = ['reference_type' => 'treasury_reclassification', 'reference_id' => (int) $r['treasury_id']];
$lines[] = $tag + [
'account_id' => (int) $r['correct_account_id'],
'debit' => $net,
'credit' => '0.00',
'description_ar' => $desc,
];
$lines[] = $tag + [
'account_id' => (int) $r['wrong_account_id'],
'debit' => '0.00',
'credit' => $net,
'description_ar' => $desc,
];
$moved = bcadd($moved, $net, self::SCALE);
}
if (count($lines) < 2) {
return ['success' => false, 'journal_entry_id' => null, 'error' => 'مفيش مبالغ موجبة تتنقل', 'moved' => '0.00'];
}
$result = JournalService::createEntry([
'entry_date' => $entryDate ?: date('Y-m-d'),
'description_ar' => 'تصحيح تبويب نقدية الخزائن الفرعية',
'description_en' => 'Reclassification of sub-treasury cash',
'reference_type' => 'treasury_reclassification',
'source_module' => 'accounting',
'is_auto_generated' => 0,
'notes' => $note ?: 'الخزائن الفرعية كانت مربوطة بصناديق العملات الأجنبية، '
. 'فالتحصيل بالجنيه كان بينزل في الصندوق بالدولار/باليورو. '
. 'كل خزنة بقى ليها حسابها، والقيد ده بينقل الأرصدة لحسابها الصح. '
. 'القيود القديمة زي ما هي — ده تصحيح تبويب مش تعديل تاريخ.',
], $lines, true);
if (empty($result['success'])) {
return [
'success' => false, 'journal_entry_id' => null, 'moved' => '0.00',
'error' => $result['error'] ?? 'فشل إنشاء القيد',
];
}
return [
'success' => true,
'journal_entry_id' => (int) $result['journal_entry_id'],
'error' => null,
'moved' => $moved,
];
}
/** Safes with no account of their own — the chain cannot run for these. */
public static function unprovisioned(): array
{
return App::getInstance()->db()->select(
"SELECT t.id, t.code, t.name_ar, t.type, t.account_code
FROM treasuries t
WHERE t.is_active = 1
AND (t.gl_account_id IS NULL
OR NOT EXISTS (SELECT 1 FROM chart_of_accounts a
WHERE a.id = t.gl_account_id
AND a.is_header = 0 AND a.is_active = 1 AND a.is_archived = 0))
ORDER BY t.type DESC, t.id"
);
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الاستحقاقات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div style="flex:1;min-width:320px;">
<h2 style="margin:6px 0 4px;">الاستحقاقات — الفلوس اللي لينا وعلينا قبل ما حد يدفع</h2>
<p style="margin:0;color:#6B7280;font-size:13px;line-height:1.9;max-width:800px;">
لما النادي يستحق فلوس — اشتراك اتولّد، ملعب اتحجز، فاتورة إيجار اتعملت — الدفاتر
المفروض تعرف على طول، مش تستنى لحد ما حد يدفع. من غير كده النادي بيقلّل اللي ليه،
والإيراد بينزل في الشهر الغلط.
<br><br>
الماسح بيقرا الجداول نفسها كل ليلة ويقيّد اللي لسه ما اتقيّدش، وبعدين يقفل اللي
اتحصّل. مش مستني حدث يترسل — لو الحدث ما اتبعتش أصلًا، الماسح بياخد باله في الجولة
اللي بعدها. وشغّاله كذا مرة مش بيغيّر حاجة.
</p>
</div>
<?php if ($ready): ?>
<form method="POST" action="/accounting/accruals/run" style="margin-top:8px;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary">شغّل الفحص دلوقتي</button>
</form>
<?php endif; ?>
</div>
<?php if (!$ready): ?>
<div class="card" style="border-right:3px solid #DC2626;">
<div style="padding:16px 18px;color:#991B1B;">
جدول الاستحقاقات لسه مش منصّب. شغّل <code>php cli.php migrate</code> ثم <code>php cli.php seed</code>.
</div>
</div>
<?php else: ?>
<?php if (!empty($lastResult)): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid <?= empty($lastResult['errors']) ? '#059669' : '#D97706' ?>;">
<div style="padding:14px 18px;font-size:13px;line-height:1.9;">
آخر فحص <?= e($lastResult['at']) ?>
قيّد <strong><?= number_format((int) $lastResult['accrued_claims']) ?></strong> مطالبة
بـ<strong><?= money($lastResult['accrued_total']) ?></strong>،
وأقفل <strong><?= number_format((int) $lastResult['released_claims']) ?></strong>
بـ<strong><?= money($lastResult['released_total']) ?></strong>.
<?php foreach ($lastResult['errors'] as $err): ?>
<div style="color:#991B1B;margin-top:6px;"><?= e($err) ?></div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- ══ Totals ══ -->
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;">
<div style="font-size:11px;color:#6B7280;">إجمالي اللي اتقيّد</div>
<div style="font-size:22px;font-weight:700;"><?= money($accrued) ?></div>
</div>
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;border-right:3px solid #D97706;">
<div style="font-size:11px;color:#6B7280;">لسه مفتوح (مستحق ولا اتحصّلش)</div>
<div style="font-size:22px;font-weight:700;color:#92400E;"><?= money($open) ?></div>
</div>
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;">
<div style="font-size:11px;color:#6B7280;">عدد المصادر الموصّلة</div>
<div style="font-size:22px;font-weight:700;"><?= count($runners) ?></div>
</div>
</div>
<!-- ══ Per stream ══ -->
<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($status)): ?>
<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><th>آخر تحديث</th></tr></thead>
<tbody>
<?php foreach ($status as $s): ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($s['stream_name'] ?? $s['stream_code']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($s['stream_code']) ?></div>
</td>
<td><?= number_format((int) $s['claims']) ?></td>
<td style="font-weight:600;"><?= money($s['accrued']) ?></td>
<td style="color:<?= bccomp((string) $s['open_amount'], '0.00', 2) > 0 ? '#92400E' : '#9CA3AF' ?>;font-weight:600;">
<?= money($s['open_amount']) ?>
</td>
<td style="font-size:12px;color:#6B7280;"><?= e($s['first_date'] ?? '—') ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e(substr((string) $s['last_run'], 0, 16)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- ══ Oldest open ══ -->
<?php if (!empty($oldest)): ?>
<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 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><th>المبلغ</th></tr></thead>
<tbody>
<?php foreach ($oldest as $o): ?>
<?php
$age = $o['due_date'] ? (int) ((strtotime(date('Y-m-d')) - strtotime((string) $o['due_date'])) / 86400) : null;
$late = $age !== null && $age > 30;
?>
<tr<?= $late ? ' style="background:#FEF2F2;"' : '' ?>>
<td style="font-size:12px;color:#6B7280;"><?= e($o['due_date'] ?? '—') ?></td>
<td style="font-size:12px;color:<?= $late ? '#991B1B' : '#6B7280' ?>;font-weight:<?= $late ? '600' : '400' ?>;">
<?= $age !== null ? $age . ' يوم' : '—' ?>
</td>
<td><?= e($o['member_name'] ?: ($o['counterparty_name'] ?: '—')) ?></td>
<td style="font-size:11.5px;color:#6B7280;direction:ltr;text-align:right;"><?= e($o['stream_code']) ?></td>
<td style="font-size:11.5px;direction:ltr;text-align:right;"><?= e($o['document_number'] ?: ($o['document_type'] . '#' . $o['document_id'])) ?></td>
<td style="font-weight:600;"><?= money($o['accrued_amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ What it refuses to book ══ -->
<?php if (!empty($unbookable)): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #6B7280;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">حاجات الماسح رفض يقيّدها — وليه</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;line-height:1.8;">
دي مش مشاكل ربط. دي حاجات فيها فلوس ضمنيًا بس مفيش مبلغ مسجّل ولا جهة محددة،
فأي رقم هنحطه هيبقى تخمين. ورقم غلط في الدفاتر أصعب في اكتشافه من رقم ناقص،
وكمان بيبان إنه مظبوط.
</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 ($unbookable as $u): ?>
<tr>
<td>
<div style="font-weight:600;"><?= e($u['label']) ?></div>
<div style="font-size:11px;color:#9CA3AF;direction:ltr;text-align:right;"><?= e($u['stream']) ?></div>
</td>
<td><?= number_format((int) $u['rows']) ?></td>
<td style="font-size:12px;color:#374151;max-width:380px;line-height:1.8;"><?= e($u['why']) ?></td>
<td style="font-size:12px;color:#065F46;max-width:280px;line-height:1.8;"><?= e($u['needs']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ Runs ══ -->
<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($runs)): ?>
<div style="padding:24px;text-align:center;color:#6B7280;font-size:13px;">
الفحص التلقائي لسه ما اشتغلش. الكرون بيتشحن مقفول في النظام ده —
فعّله من الإعدادات (<code>cron_enabled = 1</code>) أو شغّل الفحص بإيدك من فوق.
</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 ($runs as $r): ?>
<tr>
<td style="font-size:12px;color:#6B7280;"><?= e(substr((string) $r['started_at'], 0, 16)) ?></td>
<td>
<?php if (($r['status'] ?? '') === 'success'): ?><span class="badge badge-success">تم</span>
<?php elseif (($r['status'] ?? '') === 'failed'): ?><span class="badge badge-danger">فشل</span>
<?php else: ?><span class="badge badge-neutral"><?= e($r['status'] ?? '') ?></span><?php endif; ?>
</td>
<td style="font-size:12px;color:#6B7280;">
<?= $r['execution_time_ms'] !== null ? number_format((int) $r['execution_time_ms']) . ' مللي' : '—' ?>
</td>
<td style="font-size:12px;"><?= number_format((int) ($r['records_processed'] ?? 0)) ?></td>
<td style="font-size:12px;color:#991B1B;max-width:420px;"><?= e(mb_substr((string) ($r['error_message'] ?? ''), 0, 240)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</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;">
<h2 style="margin:6px 0 4px;">مسار الفلوس</h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:720px;line-height:1.7;">
شاشة توزيع المبالغ بتقول المبلغ الواحد يتقسّم على أنهي حسابات في قيد واحد.
الشاشة دي بتقول حاجة تانية خالص: نفس الفلوس وهي بتتنقّل من حساب لحساب على مدى
كذا قيد، كل ما يحصل أكشن جديد. كل خطوة لازم <strong>تفضّي</strong> الحساب اللي
الخطوة اللي قبلها حطّت فيه الفلوس — ولو ما فضّتوش، يبقى فيه فلوس واقفة في النص
ومحدش واخد باله.
</p>
</div>
<?php if (!$ready): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #DC2626;">
<div style="padding:16px 18px;color:#991B1B;">
جداول السلاسل لسه مش منصّبة على قاعدة البيانات دي. شغّل <code>php cli.php migrate</code> ثم <code>php cli.php seed</code>.
</div>
</div>
<?php else: ?>
<!-- ══ Safes with no account of their own ══ -->
<?php if (!empty($unprovisioned)): ?>
<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;line-height:1.7;">
الخزنة اللي ملهاش حساب بتاعها مش ممكن تدخل السلسلة: التسوية هتفضّي حساب
التحصيل ما نزلش فيه أصلًا. شغّل الترحيلات (<code>php cli.php migrate</code>)
عشان كل خزنة تاخد حسابها.
</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 ($unprovisioned as $u): ?>
<tr>
<td style="direction:ltr;text-align:right;font-family:monospace;font-size:12px;"><?= e($u['code']) ?></td>
<td><?= e($u['name_ar']) ?></td>
<td><?= $u['type'] === 'main' ? 'رئيسية' : 'فرعية' ?></td>
<td style="direction:ltr;text-align:right;color:#991B1B;"><?= e($u['account_code'] ?: '—') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<!-- ══ Cash booked into the wrong box by the old wiring ══ -->
<?php if (!empty($reclassify['rows'])): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #D97706;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
<div>
<h3 style="margin:0;font-size:14px;color:#92400E;">نقدية خزائن متسجّلة في صناديق العملات الأجنبية</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;line-height:1.7;">
إجمالي <strong style="color:#92400E;"><?= money($reclassify['total']) ?></strong> جنيه
اتحصّل بالجنيه في خزائن فرعية بس نزل في «الصندوق بالدولار» و«الصندوق باليورو»،
لأن الخزائن كانت مربوطة بيهم. القيود القديمة زي ما هي — التصحيح قيد تبويب جديد.
</div>
</div>
<a href="/accounting/posting-chains/reclassification" class="btn btn-primary">مراجعة التصحيح</a>
</div>
</div>
<?php endif; ?>
<!-- ══ Hops that failed and were never retried ══ -->
<?php if (!empty($failedHops)): ?>
<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><th>السبب</th></tr></thead>
<tbody>
<?php foreach ($failedHops as $f): ?>
<tr>
<td style="font-size:12px;color:#6B7280;"><?= e(substr((string) $f['posted_at'], 0, 16)) ?></td>
<td><?= e($f['chain_name']) ?></td>
<td><?= e($f['step_name']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($f['reference_number'] ?: '—') ?></td>
<td style="font-weight:600;"><?= money($f['amount']) ?></td>
<td style="font-size:12px;color:#991B1B;max-width:340px;"><?= e($f['message'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<div style="margin-bottom:12px;">
<a href="/accounting/posting-chains/parked" class="btn btn-outline">فين الفلوس دلوقتي؟ — الأرصدة الواقفة وأعمارها</a>
</div>
<!-- ══ The chains ══ -->
<?php foreach ($chains as $c): ?>
<?php
$id = (int) $c['id'];
$h = $health[$id] ?? ['ok' => false, 'errors' => [], 'warnings' => [], 'steps' => 0];
$p = $parked[$id] ?? ['total' => '0.00', 'oldest_days' => null];
$accent = !$h['ok'] ? '#DC2626' : ($h['warnings'] ? '#D97706' : '#059669');
?>
<div class="card" style="margin-bottom:14px;border-right:3px solid <?= $accent ?>;">
<div style="padding:14px 18px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div style="flex:1;min-width:280px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<a href="/accounting/posting-chains/<?= $id ?>" style="font-size:15px;font-weight:700;color:#111827;text-decoration:none;">
<?= e($c['name_ar']) ?>
</a>
<span class="badge badge-neutral"><?= e($domains[$c['domain']] ?? $c['domain']) ?></span>
<?php if ((int) $c['is_active'] === 0): ?>
<span class="badge badge-warning">موقوفة</span>
<?php elseif (!$h['ok']): ?>
<span class="badge badge-danger"><?= count($h['errors']) ?> مشكلة</span>
<?php elseif ($h['warnings']): ?>
<span class="badge badge-warning"><?= count($h['warnings']) ?> ملاحظة</span>
<?php else: ?>
<span class="badge badge-success">سليمة</span>
<?php endif; ?>
</div>
<p style="margin:8px 0 0;color:#6B7280;font-size:12.5px;line-height:1.8;">
<?= e($c['description_ar'] ?? '') ?>
</p>
<?php if (!empty($h['errors'])): ?>
<div style="margin-top:8px;font-size:12px;color:#991B1B;line-height:1.8;">
<?php foreach (array_slice($h['errors'], 0, 3) as $err): ?>
<div><?= e($err) ?></div>
<?php endforeach; ?>
<?php if (count($h['errors']) > 3): ?>
<div style="color:#6B7280;">و<?= count($h['errors']) - 3 ?> غيرها…</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div style="text-align:left;min-width:150px;">
<div style="font-size:11px;color:#6B7280;">فلوس واقفة في السلسلة دي</div>
<div style="font-size:19px;font-weight:700;color:<?= bccomp($p['total'], '0.00', 2) > 0 ? '#111827' : '#9CA3AF' ?>;">
<?= money($p['total']) ?>
</div>
<?php if ($p['oldest_days'] !== null && $p['oldest_days'] > 0): ?>
<div style="font-size:11px;color:<?= $p['oldest_days'] > 7 ? '#B45309' : '#6B7280' ?>;margin-top:2px;">
أقدم مبلغ من <?= (int) $p['oldest_days'] ?> يوم
</div>
<?php endif; ?>
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;"><?= (int) $h['steps'] ?> خطوة</div>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($chains)): ?>
<div class="card"><div style="padding:30px;text-align:center;color:#6B7280;">
مفيش سلاسل معرّفة. شغّل <code>php cli.php seed</code>.
</div></div>
<?php endif; ?>
<?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/posting-chains" 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;line-height:1.8;">
الحسابات دي مش محطة نهائية — دي حسابات الفلوس بتعدّي منها وهي في طريقها لحتة تانية.
الطبيعي إنها تفضى أول بأول. لو رصيد قعد فيها، يبقى فيه خطوة ما حصلتش: وردية ما اتسوّتش،
إيداع ما اتأكّدش، أو شيك البنك ما ردّش عليه. العمر محسوب بطريقة «الأقدم يخرج الأول»،
فاللي ظاهر قدامك هو المبالغ اللي لسه فعلًا واقفة، مش مجرد رصيد إجمالي.
</p>
</div>
<!-- ══ Totals ══ -->
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;">
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;">
<div style="font-size:11px;color:#6B7280;">إجمالي الفلوس الواقفة</div>
<div style="font-size:22px;font-weight:700;color:#111827;"><?= money($total) ?></div>
</div>
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;<?= bccomp($overdue, '0.00', 2) > 0 ? 'border-right:3px solid #DC2626;' : '' ?>">
<div style="font-size:11px;color:#6B7280;">منها متأخر عن المهلة المفروضة</div>
<div style="font-size:22px;font-weight:700;color:<?= bccomp($overdue, '0.00', 2) > 0 ? '#991B1B' : '#9CA3AF' ?>;">
<?= money($overdue) ?>
</div>
</div>
<div class="card" style="flex:1;min-width:200px;padding:14px 18px;">
<div style="font-size:11px;color:#6B7280;">عدد الحسابات الوسيطة</div>
<div style="font-size:22px;font-weight:700;color:#111827;"><?= count($rows) ?></div>
</div>
</div>
<!-- ══ Per account ══ -->
<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($rows)): ?>
<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>
<?php foreach ($buckets as $b): ?><th style="font-size:11px;"><?= e($b['label']) ?></th><?php endforeach; ?>
<th>أقدم مبلغ</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<?php
$late = $r['expected_days'] !== null && bccomp($r['overdue_amount'], '0.00', 2) > 0;
?>
<tr<?= $late ? ' style="background:#FEF2F2;"' : '' ?>>
<td>
<div style="font-weight:600;"><?= e($r['account_label']) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= e(implode('، ', $r['chains'])) ?></div>
</td>
<td style="font-size:12px;"><?= e($r['step_name']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['next_step_name'] ?? '—') ?></td>
<td style="font-weight:700;<?= bccomp($r['balance'], '0.00', 2) < 0 ? 'color:#991B1B;' : '' ?>">
<?= money($r['balance']) ?>
</td>
<?php foreach ($r['buckets'] as $b): ?>
<td style="font-size:12px;color:<?= bccomp($b['amount'], '0.00', 2) > 0 ? '#374151' : '#D1D5DB' ?>;">
<?= bccomp($b['amount'], '0.00', 2) > 0 ? money($b['amount']) : '—' ?>
</td>
<?php endforeach; ?>
<td style="font-size:12px;color:<?= $late ? '#991B1B' : '#6B7280' ?>;">
<?= $r['oldest_days'] !== null ? (int) $r['oldest_days'] . ' يوم' : '—' ?>
<?php if ($r['expected_days'] !== null): ?>
<div style="font-size:10px;color:#9CA3AF;">المهلة <?= (int) $r['expected_days'] ?></div>
<?php endif; ?>
</td>
<td>
<a href="/accounting/posting-chains/parked?account=<?= (int) $r['account_id'] ?>" class="btn btn-sm btn-outline">تفاصيل</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<!-- ══ One account, item by item ══ -->
<?php if ($detail !== null): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #2563EB;">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">اللي لسه واقف في «<?= e($detail['account_label']) ?>»</h3>
<div style="font-size:12px;color:#6B7280;margin-top:4px;">
كل سطر هنا مبلغ نزل الحساب وما خرجش منه لحد دلوقتي. لو المبلغ خرج جزئيًا، الباقي بس هو اللي ظاهر.
<?php if (($detail['items_total'] ?? 0) > count($detail['items'])): ?>
<br><strong style="color:#92400E;">
ظاهر أحدث <?= number_format(count($detail['items'])) ?> بند من إجمالي
<?= number_format((int) $detail['items_total']) ?> — الإجمالي فوق محسوب على الكل مش على المعروض.
</strong>
<?php endif; ?>
</div>
</div>
<?php if (empty($detail['items'])): ?>
<div style="padding:30px;text-align:center;color:#059669;">الحساب فاضي — كل حاجة نزلت فيه خرجت منه.</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><th>الباقي واقف</th></tr></thead>
<tbody>
<?php foreach ($detail['items'] as $i): ?>
<?php $overdue = $detail['expected_days'] !== null && (int) $i['age_days'] > (int) $detail['expected_days']; ?>
<tr<?= $overdue ? ' style="background:#FEF2F2;"' : '' ?>>
<td style="font-size:12px;color:#6B7280;"><?= e($i['date']) ?></td>
<td style="font-size:12px;color:<?= $overdue ? '#991B1B' : '#6B7280' ?>;font-weight:<?= $overdue ? '600' : '400' ?>;">
<?= (int) $i['age_days'] ?> يوم
</td>
<td>
<a href="/accounting/journal-entries/<?= (int) $i['entry_id'] ?>"
style="direction:ltr;display:inline-block;font-size:12px;color:#2563EB;text-decoration:none;">
<?= e($i['entry_number']) ?>
</a>
</td>
<td style="font-size:12px;max-width:320px;"><?= e($i['description']) ?></td>
<td style="direction:ltr;text-align:right;font-size:11.5px;color:#6B7280;">
<?= e($i['reference_number'] ?: (($i['reference_type'] ?? '') . ' ' . ($i['reference_id'] ?? ''))) ?>
</td>
<td style="font-weight:600;"><?= money($i['amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- ══ Hops that failed ══ -->
<?php if (!empty($failed)): ?>
<div class="card" style="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><th>السبب</th></tr></thead>
<tbody>
<?php foreach ($failed as $f): ?>
<tr>
<td style="font-size:12px;color:#6B7280;"><?= e(substr((string) $f['posted_at'], 0, 16)) ?></td>
<td style="font-size:12px;"><?= e($f['chain_name']) ?></td>
<td style="font-size:12px;"><?= e($f['step_name']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($f['reference_number'] ?: '—') ?></td>
<td style="font-weight:600;"><?= money($f['amount']) ?></td>
<td style="font-size:12px;color:#991B1B;max-width:340px;"><?= e($f['message'] ?? '') ?></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/posting-chains" 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;line-height:1.9;">
الخزائن الفرعية كانت مربوطة بصناديق العملات الأجنبية في دليل الحسابات، فكل جنيه
اتحصّل على المكتب نزل في «الصندوق بالدولار» أو «الصندوق باليورو». دلوقتي كل خزنة
بقى ليها حسابها الخاص، بس الأرصدة القديمة لسه في مكانها الغلط.
<br><br>
<strong>القيود القديمة مش هتتغيّر.</strong> تعديل قيد مرحّل مش تصحيح — التصحيح
قيد جديد بتاريخ النهاردة بينقل الأرصدة لحسابها الصح، وبيفضل ظاهر ومعكوس زي أي قيد
تاني لو حد سأل بعد كده.
</p>
</div>
<?php if (!empty($unprovisioned)): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #DC2626;">
<div style="padding:14px 18px;color:#991B1B;font-size:13px;line-height:1.8;">
فيه خزائن لسه ملهاش حساب خاص بيها، فالتصحيح مش هيعرف ينقل فلوسها لفين.
شغّل <code>php cli.php migrate</code> الأول.
</div>
</div>
<?php endif; ?>
<?php if (empty($preview['rows'])): ?>
<div class="card">
<div style="padding:34px;text-align:center;color:#059669;font-size:14px;">
مفيش حاجة محتاجة تصحيح — نقدية كل خزنة في حسابها الصح.
</div>
</div>
<?php else: ?>
<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 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>
<th>آخر تاريخ</th>
<th>المبلغ</th>
</tr>
</thead>
<tbody>
<?php foreach ($preview['rows'] as $r): ?>
<tr>
<td style="font-weight:600;"><?= e($r['treasury_name']) ?></td>
<td style="color:#991B1B;">
<span style="direction:ltr;display:inline-block;font-family:monospace;font-size:11.5px;"><?= e($r['wrong_code']) ?></span>
<?= e($r['wrong_name']) ?>
</td>
<td style="color:#065F46;">
<span style="direction:ltr;display:inline-block;font-family:monospace;font-size:11.5px;"><?= e($r['correct_code'] ?? '—') ?></span>
<?= e($r['correct_name'] ?? '—') ?>
</td>
<td><?= number_format((int) $r['line_count']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['first_date']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($r['last_date']) ?></td>
<td style="font-weight:700;"><?= money($r['net_amount']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="background:#F9FAFB;">
<td colspan="6" style="text-align:left;font-weight:600;">الإجمالي</td>
<td style="font-weight:700;font-size:15px;"><?= money($preview['total']) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="card">
<div style="padding:12px 18px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;font-size:14px;">ترحيل التصحيح</h3>
</div>
<form method="POST" action="/accounting/posting-chains/reclassification"
onsubmit="return confirm('هيتم ترحيل قيد تصحيح بمبلغ <?= money($preview['total']) ?> جنيه. متأكد؟');">
<?= csrf_field() ?>
<div style="padding:16px 18px;display:flex;gap:16px;flex-wrap:wrap;align-items:flex-end;">
<div style="min-width:200px;">
<label class="form-label">تاريخ القيد</label>
<input type="date" name="entry_date" class="form-input" value="<?= e($preview['as_of']) ?>">
<div style="font-size:11px;color:#9CA3AF;margin-top:4px;">لازم يكون في فترة مفتوحة</div>
</div>
<div style="flex:1;min-width:280px;">
<label class="form-label">ملاحظة على القيد (اختياري)</label>
<input type="text" name="notes" class="form-input" placeholder="سبب التصحيح، أو رقم المذكرة المعتمدة">
</div>
<div>
<button type="submit" class="btn btn-primary">رحّل قيد التصحيح</button>
</div>
</div>
</form>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($chain['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$hopLabels = [
'transfer' => 'نقل بين حسابين',
'contract' => 'سداد — الطرفين بينقصوا',
'expand' => 'استحقاق — الطرفين بيزيدوا',
];
?>
<div style="margin-bottom:16px;">
<a href="/accounting/posting-chains" style="color:#6B7280;font-size:13px;text-decoration:none;">→ رجوع لمسار الفلوس</a>
<h2 style="margin:6px 0 4px;"><?= e($chain['name_ar']) ?></h2>
<p style="margin:0;color:#6B7280;font-size:13px;max-width:760px;line-height:1.8;">
<?= e($chain['description_ar'] ?? '') ?>
</p>
</div>
<?php if (!empty($health['errors'])): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #DC2626;">
<div style="padding:14px 18px;">
<h3 style="margin:0 0 8px;font-size:14px;color:#991B1B;">مشاكل بتوقف السلسلة</h3>
<div style="font-size:12.5px;color:#991B1B;line-height:1.9;">
<?php foreach ($health['errors'] as $err): ?><div><?= e($err) ?></div><?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<?php if (!empty($health['warnings'])): ?>
<div class="card" style="margin-bottom:16px;border-right:3px solid #D97706;">
<div style="padding:14px 18px;">
<h3 style="margin:0 0 8px;font-size:14px;color:#92400E;">ملاحظات</h3>
<div style="font-size:12.5px;color:#92400E;line-height:1.9;">
<?php foreach ($health['warnings'] as $w): ?><div><?= e($w) ?></div><?php endforeach; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- ══ The route, step by step ══ -->
<?php foreach ($steps as $no => $s): ?>
<?php
$stepAccent = $s['errors'] ? '#DC2626' : ($s['warnings'] ? '#D97706' : ($s['is_branch'] ? '#6B7280' : '#059669'));
$rows = $clearing[$no] ?? [];
$stepParked = '0.00';
$stepOldest = null;
foreach ($rows as $r) {
$stepParked = bcadd($stepParked, $r['balance'], 2);
if ($r['oldest_days'] !== null && ($stepOldest === null || $r['oldest_days'] > $stepOldest)) {
$stepOldest = $r['oldest_days'];
}
}
?>
<div class="card" style="margin-bottom:12px;border-right:3px solid <?= $stepAccent ?>;">
<div style="padding:14px 18px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;">
<div style="flex:1;min-width:300px;">
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<span style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:50%;background:<?= $stepAccent ?>;color:#fff;font-size:12px;font-weight:700;"><?= (int) $no ?></span>
<span style="font-size:14.5px;font-weight:700;"><?= e($s['name_ar']) ?></span>
<?php if ((int) $s['is_entry_point'] === 1): ?><span class="badge badge-neutral">بداية</span><?php endif; ?>
<?php if ((int) $s['is_terminal'] === 1): ?><span class="badge badge-success">نهاية</span><?php endif; ?>
<?php if ((int) $s['is_branch'] === 1): ?><span class="badge badge-warning">مسار بديل</span><?php endif; ?>
<?php if ((int) $s['posted_by_chain'] === 0): ?>
<span class="badge badge-neutral" title="خدمة تانية هي اللي بترحّل الخطوة دي">بترحّل من مكان تاني</span>
<?php endif; ?>
</div>
<p style="margin:8px 0 0;color:#6B7280;font-size:12.5px;line-height:1.9;">
<?= e($s['description_ar'] ?? '') ?>
</p>
<div style="margin-top:10px;font-size:12px;color:#374151;line-height:2;">
<div>
<span style="color:#9CA3AF;">نوع الحركة:</span>
<?= e($hopLabels[$s['hop_type']] ?? $s['hop_type']) ?>
</div>
<?php if (!empty($s['trigger_event'])): ?>
<div>
<span style="color:#9CA3AF;">بيشغّلها:</span>
<code style="direction:ltr;display:inline-block;font-size:11.5px;"><?= e($s['trigger_event']) ?></code>
</div>
<?php endif; ?>
<?php if ($s['relieves_step_no'] !== null): ?>
<div>
<span style="color:#9CA3AF;">بتفضّي:</span>
المرحلة <?= (int) $s['relieves_step_no'] ?>
<?= (string) $s['relieve_resolver'] === 'inherit' ? '(نفس حسابها بالظبط)' : '' ?>
</div>
<?php endif; ?>
<?php if ($s['expected_clearing_days'] !== null): ?>
<div>
<span style="color:#9CA3AF;">المفروض تتفضّي خلال:</span>
<?= (int) $s['expected_clearing_days'] ?> يوم
</div>
<?php endif; ?>
</div>
<?php foreach ($s['errors'] as $err): ?>
<div style="margin-top:8px;font-size:12px;color:#991B1B;"><?= e($err) ?></div>
<?php endforeach; ?>
<?php foreach ($s['warnings'] as $w): ?>
<div style="margin-top:6px;font-size:12px;color:#92400E;"><?= e($w) ?></div>
<?php endforeach; ?>
</div>
<?php if ((int) $s['is_terminal'] === 0 && $rows): ?>
<div style="text-align:left;min-width:140px;">
<div style="font-size:11px;color:#6B7280;">واقف هنا دلوقتي</div>
<div style="font-size:18px;font-weight:700;color:<?= bccomp($stepParked, '0.00', 2) > 0 ? '#111827' : '#9CA3AF' ?>;">
<?= money($stepParked) ?>
</div>
<?php if ($stepOldest !== null && $stepOldest > 0): ?>
<div style="font-size:11px;color:<?= ($s['expected_clearing_days'] !== null && $stepOldest > (int) $s['expected_clearing_days']) ? '#B45309' : '#6B7280' ?>;margin-top:2px;">
أقدم مبلغ من <?= (int) $stepOldest ?> يوم
</div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<!-- The two ends of this hop, resolved -->
<div style="margin-top:12px;padding-top:12px;border-top:1px dashed #E5E7EB;display:flex;gap:20px;flex-wrap:wrap;">
<?php if (!empty($s['relieve_accounts'])): ?>
<div style="flex:1;min-width:230px;">
<div style="font-size:11px;color:#9CA3AF;margin-bottom:4px;">بيتفضّى من</div>
<?php foreach ($s['relieve_accounts'] as $a): ?>
<div style="font-size:12.5px;color:#374151;"><?= e($a['label']) ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($s['parks_accounts'])): ?>
<div style="flex:1;min-width:230px;">
<div style="font-size:11px;color:#9CA3AF;margin-bottom:4px;">وبينزل في</div>
<?php foreach ($s['parks_accounts'] as $a): ?>
<div style="font-size:12.5px;color:#374151;">
<?= e($a['label']) ?>
<?php if ((int) $s['is_terminal'] === 0): ?>
<a href="/accounting/posting-chains/parked?account=<?= (int) $a['account_id'] ?>"
style="font-size:11px;color:#2563EB;text-decoration:none;margin-right:6px;">تفاصيل الرصيد</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
<!-- ══ What actually moved ══ -->
<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($hops)): ?>
<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><th>الحالة</th><th>ملاحظة</th></tr></thead>
<tbody>
<?php foreach ($hops as $h): ?>
<tr>
<td style="font-size:12px;color:#6B7280;"><?= e(substr((string) $h['posted_at'], 0, 16)) ?></td>
<td><?= e($h['step_name']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($h['reference_number'] ?: '—') ?></td>
<td style="font-weight:600;"><?= money($h['amount']) ?></td>
<td>
<?php if (!empty($h['journal_entry_id'])): ?>
<a href="/accounting/journal-entries/<?= (int) $h['journal_entry_id'] ?>"
style="direction:ltr;display:inline-block;font-size:12px;color:#2563EB;text-decoration:none;">
<?= e($h['entry_number'] ?? ('#' . $h['journal_entry_id'])) ?>
</a>
<?php else: ?><?php endif; ?>
</td>
<td>
<?php if ($h['outcome'] === 'posted'): ?><span class="badge badge-success">تم</span>
<?php elseif ($h['outcome'] === 'failed'): ?><span class="badge badge-danger">فشل</span>
<?php else: ?><span class="badge badge-neutral">متخطّاة</span><?php endif; ?>
</td>
<td style="font-size:12px;color:#991B1B;max-width:320px;"><?= e($h['message'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
......@@ -107,6 +107,14 @@ PermissionRegistry::register('accounting', [
'accounting.revenue_mapping.view' => ['ar' => 'عرض توزيع الإيرادات', 'en' => 'View Revenue Mapping'],
'accounting.revenue_mapping.manage' => ['ar' => 'إدارة توزيع الإيرادات', 'en' => 'Manage Revenue Mapping'],
// Posting Chains (the route money takes between accounts)
'accounting.chains.view' => ['ar' => 'عرض مسار الفلوس', 'en' => 'View Posting Chains'],
'accounting.chains.manage' => ['ar' => 'إدارة وتصحيح مسار الفلوس', 'en' => 'Manage Posting Chains'],
// Accruals (obligations booked before collection)
'accounting.accruals.view' => ['ar' => 'عرض الاستحقاقات', 'en' => 'View Accruals'],
'accounting.accruals.manage' => ['ar' => 'تشغيل فحص الاستحقاقات', 'en' => 'Run Accrual Scan'],
// Vouchers
'accounting.voucher.view' => ['ar' => 'عرض السندات', 'en' => 'View Vouchers'],
'accounting.voucher.create' => ['ar' => 'إنشاء سند', 'en' => 'Create Voucher'],
......@@ -137,6 +145,9 @@ MenuRegistry::register('accounting', [
['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' => 'Connection Centre', 'route' => '/accounting/revenue-mapping/connections', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
['label_ar' => 'مسار الفلوس', 'label_en' => 'Posting Chains', 'route' => '/accounting/posting-chains', 'permission' => 'accounting.chains.view', 'order' => 2],
['label_ar' => 'فين الفلوس دلوقتي', 'label_en' => 'Money in Transit', 'route' => '/accounting/posting-chains/parked', 'permission' => 'accounting.chains.view', 'order' => 2],
['label_ar' => 'الاستحقاقات', 'label_en' => 'Accruals', 'route' => '/accounting/accruals', 'permission' => 'accounting.accruals.view', 'order' => 2],
['label_ar' => 'المطالبات والتحصيل', 'label_en' => 'Billing & Collection', 'route' => '/accounting/billing', 'permission' => 'accounting.billing.view', 'order' => 2],
['label_ar' => 'سندات الصرف والقبض', 'label_en' => 'Vouchers', 'route' => '/accounting/vouchers', 'permission' => 'accounting.voucher.view', 'order' => 3],
['label_ar' => 'الإيراد المؤجل', 'label_en' => 'Deferred Revenue', 'route' => '/accounting/revenue-mapping/recognition', 'permission' => 'accounting.revenue_mapping.view', 'order' => 2],
......@@ -469,6 +480,33 @@ EventBus::listen('treasury.deposit.confirmed', function (array $data): void {
}
}, 50);
// ── Operational postings ────────────────────────────────────
// Money leaving the club, and value moving between assets. Every one of these
// events was already being dispatched or is dispatched now; none of them had a
// listener, so cash left for staff loans and end-of-service with no entry at
// all, stock arrived without increasing assets, and depreciation aged the asset
// register but never the balance sheet.
$opPostings = [
'hr.loan.disbursed' => 'onLoanDisbursed',
'hr.end_of_service.paid' => 'onEndOfServicePaid',
'coach.payment.approved' => 'onCoachPaymentApproved',
'procurement.grn_completed' => 'onGoodsReceived',
'inventory.depreciation_run' => 'onDepreciationRun',
'inventory.audit_completed' => 'onStockAuditApproved',
'inventory.asset_disposed' => 'onAssetDisposed',
'fine.waived' => 'onFineWaived',
];
foreach ($opPostings as $eventName => $handler) {
EventBus::listen($eventName, static function (array $data) use ($handler, $eventName): void {
try {
\App\Modules\Accounting\Services\OperationalPostingService::$handler($data);
} catch (\Throwable $e) {
\App\Core\Logger::error("Accounting auto-post failed ({$eventName}): " . $e->getMessage());
}
}, 50);
}
// ── Statement Integration ───────────────────────────────────
// Auto-records customer/supplier transactions for account statements and credit limits
StatementIntegrationService::registerListeners();
......
......@@ -307,6 +307,16 @@ class FineController extends Controller
Logger::warning("Workflow transition failed for fine waive", ['fine_id' => (int) $id, 'error' => $e->getMessage()]);
}
// Imposing the fine raised a receivable. Waiving it only changed the
// status, so the debt stayed on the books and the member kept being
// chased for money the club had decided not to collect.
EventBus::dispatch('fine.waived', [
'fine_id' => (int) $id,
'member_id' => (int) ($fine['member_id'] ?? 0),
'amount' => (string) ($fine['amount'] ?? '0'),
'reason' => 'إعفاء من غرامة: ' . $reason,
]);
return $this->redirect('/fines')->withSuccess('تم الإعفاء من الغرامة');
}
}
\ No newline at end of file
......@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\HR\Models\HrEndOfService;
use App\Modules\HR\Models\HrEmployeeProfile;
......@@ -240,6 +241,14 @@ class EndOfServiceController extends Controller
Logger::info("End of service paid", ['record_id' => (int) $id, 'payment_date' => $paymentDate]);
// The largest single payment HR makes, and it had no ledger entry at all.
EventBus::dispatch('hr.end_of_service.paid', [
'record_id' => (int) $id,
'employee_id' => (int) ($record->employee_profile_id ?? 0),
'amount' => (string) ($record->net_settlement ?? '0'),
'paid_date' => $paymentDate,
]);
return $this->redirect('/hr/end-of-service/' . $id)->withSuccess('تم صرف مستحقات نهاية الخدمة');
}
}
......@@ -154,6 +154,17 @@ final class LoanService
Logger::warning("Workflow transition failed for loan disburse", ['loan_id' => $loanId, 'error' => $e->getMessage()]);
}
// Cash is leaving the club against a debt the employee now owes it.
// Nothing was telling accounting, so the money simply vanished from the
// books — no entry, no receivable, no trace.
EventBus::dispatch('hr.loan.disbursed', [
'loan_id' => $loanId,
'employee_id' => (int) ($loan['employee_profile_id'] ?? 0),
'loan_number' => $loan['loan_number'] ?? null,
'amount' => (string) ($loan['loan_amount'] ?? '0'),
'disbursed_date' => date('Y-m-d'),
]);
return ['success' => true];
}
......
......@@ -7,6 +7,7 @@ use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\Inventory\Models\AssetRegister;
use App\Modules\Inventory\Models\DepreciationEntry;
use App\Modules\Inventory\Models\Warehouse;
......@@ -86,6 +87,15 @@ class AssetController extends Controller
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
// Disposal has to take the cost AND its accumulated depreciation off the
// books and recognise the gain or loss. Writing the proceeds into a
// column left a fully depreciated asset sitting in fixed assets for ever.
EventBus::dispatch('inventory.asset_disposed', [
'asset_id' => (int) $id,
'disposal_value' => $disposalValue,
'reason' => $reason,
]);
return $this->redirect('/inventory/assets/' . $id)->withSuccess('تم تسجيل التصرف في الأصل');
}
......
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\App;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\Accounting\Services\Revenue\AccrualRunner;
/**
* Nightly: book every obligation the ledger has not been told about, then close
* the ones that have since been paid.
*
* This is a reconciler, not a trigger. It does not care whether an event fired,
* whether it fired under the right name, or whether the request that raised the
* obligation crashed before dispatching anything — it reads the source tables
* and books the difference. A pass that finds nothing new does nothing at all.
*
* Runs once a day rather than hourly. Accruals are dated, not timed: booking a
* subscription at 02:00 and again at 03:00 achieves nothing except two entries
* where one belongs, and the journal is easier to read with one accrual line per
* day per stream.
*
* Safe to run by hand at any time, and safe to run twice.
*/
class AccrualReconcileJob
{
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
/**
* Once a day. The runner ticks hourly, so this checks whether today's pass
* already completed rather than relying on the schedule.
*/
public function shouldRun(): bool
{
try {
$row = $this->db->selectOne(
"SELECT id FROM cron_job_log
WHERE job_name = 'AccrualReconcileJob'
AND status = 'success'
AND DATE(started_at) = CURDATE()
LIMIT 1"
);
return $row === null;
} catch (\Throwable) {
return false; // log table missing — the runner is not ready
}
}
public function run(): array
{
// Services reach the connection through the App singleton, which the CLI
// bootstrap does not bind on its own.
App::getInstance()->setDb($this->db);
$result = AccrualRunner::runAll();
$accruedClaims = 0;
$accruedTotal = '0.00';
$errors = [];
foreach ($result['accrued'] as $runner => $r) {
$accruedClaims += (int) $r['posted'];
$accruedTotal = bcadd($accruedTotal, (string) $r['total'], 2);
if ($r['error'] !== null) {
$errors[] = $runner . ': ' . $r['error'];
}
}
$releasedClaims = 0;
$releasedTotal = '0.00';
foreach ($result['released'] as $type => $r) {
$releasedClaims += (int) $r['posted'];
$releasedTotal = bcadd($releasedTotal, (string) $r['total'], 2);
if ($r['error'] !== null) {
$errors[] = $type . ': ' . $r['error'];
}
}
if ($errors) {
Logger::error('Accrual reconcile finished with errors', ['errors' => $errors]);
}
return [
'accrued_claims' => $accruedClaims,
'accrued_total' => $accruedTotal,
'released_claims' => $releasedClaims,
'released_total' => $releasedTotal,
'errors' => $errors,
'message' => "قيّد {$accruedClaims} مطالبة بـ{$accruedTotal}، "
. "وأقفل {$releasedClaims} بـ{$releasedTotal}",
];
}
}
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Posting chains — the money's route, not just its split.
*
* The allocation engine (revenue_posting_rules) answers one question well: given
* an amount, which accounts share it inside ONE journal entry. It has no answer
* for the other half of real bookkeeping — the same money moving through a
* SEQUENCE of entries as separate actions happen over time:
*
* cash taken at the memberships desk → parked in that safe's account
* end-of-day settlement to main safe → relieves the sub safe, parks in main
* bank deposit confirmed → relieves main, parks at the bank
*
* Each hop must clear the account the previous hop parked into. Nothing enforced
* that before, so the treasury flow debited one generic cash account on
* collection and then posted a transfer between two unrelated accounts — the
* clearing account was never relieved because it was never the one debited.
*
* A chain makes the linkage structural rather than a matter of two rules
* happening to agree: a step declares WHERE it leaves the money, and WHICH
* earlier step it clears. The counter side of every hop is then derived, not
* typed in, so a chain cannot be authored into a state that fails to net to zero.
*
* `treasuries.gl_account_id` is added here because per-safe account resolution is
* what makes the treasury chain expressible at all. account_code stays for the
* legacy AccountCodes path and is kept in step with it.
*/
return [
'up' => static function (Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS posting_chains (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
chain_code VARCHAR(60) NOT NULL COMMENT 'treasury:cash_lifecycle, receivable:member ...',
name_ar VARCHAR(200) NOT NULL,
name_en VARCHAR(200) NULL,
domain ENUM('treasury','receivable','payable','instrument','inventory','payroll','other')
NOT NULL DEFAULT 'other',
description_ar TEXT NULL COMMENT 'plain-Arabic explanation shown above the chain diagram',
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = shipped with the system, cannot be deleted',
is_active TINYINT(1) NOT NULL DEFAULT 1,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
UNIQUE KEY uq_posting_chain_code (chain_code),
KEY idx_posting_chain_domain (domain, is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── Steps ───────────────────────────────────────────────────────
//
// Two resolver columns carry the whole idea:
//
// parks_* where this step LEAVES the money (its debit, on an inflow)
// relieve_* which account this step CLEARS (its credit, on an inflow)
//
// Both are resolved against the transaction context at post time rather
// than frozen as an account id, because "the safe that took the money"
// is a different account for every cashier and cannot be written down
// once. `relieves_step_no` + relieve_resolver='inherit' is the common
// case: clear exactly what the named earlier step parked.
$db->raw("
CREATE TABLE IF NOT EXISTS posting_chain_steps (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
chain_id BIGINT UNSIGNED NOT NULL,
step_no SMALLINT UNSIGNED NOT NULL,
name_ar VARCHAR(200) NOT NULL,
description_ar VARCHAR(500) NULL,
trigger_event VARCHAR(100) NULL COMMENT 'the EventBus event that advances the money to this step',
stream_code VARCHAR(100) NULL COMMENT 'optional revenue_streams.stream_code this step posts as',
stage ENUM('accrual','collection','payment','refund','writeoff','transfer') NULL,
-- WHAT the hop does to the balance sheet, which decides which side each
-- account lands on. transfer: value moves home (safe to safe, AR to cash).
-- contract: both sides shrink (paying a supplier — the payable and the cash
-- both go). expand: both sides grow (raising a payable against an expense).
hop_type ENUM('transfer','contract','expand') NOT NULL DEFAULT 'transfer',
-- WHO performs it. 0 means the hop is real and its accounts are checked and
-- aged like any other, but a dedicated service already posts it — the
-- allocation engine for a collection, InstrumentPostingService for a cheque.
-- Modelling those rather than ignoring them is what lets the chain screen
-- show the whole route and age every clearing account on it, without
-- rewriting posting code that already works.
posted_by_chain TINYINT(1) NOT NULL DEFAULT 1,
parks_resolver ENUM('treasury_of_txn','treasury_target','bank_of_txn','fixed_account','stream_pointer','allocation_lines','none')
NOT NULL DEFAULT 'fixed_account'
COMMENT 'how to find the account this step leaves the money in',
parks_account_id BIGINT UNSIGNED NULL COMMENT 'for parks_resolver=fixed_account',
parks_pointer VARCHAR(100) NULL COMMENT 'for parks_resolver=stream_pointer',
relieves_step_no SMALLINT UNSIGNED NULL COMMENT 'the step whose parked account this one clears',
relieve_resolver ENUM('inherit','treasury_of_txn','treasury_source','treasury_target','bank_of_txn','fixed_account','stream_pointer','none')
NOT NULL DEFAULT 'inherit',
relieve_account_id BIGINT UNSIGNED NULL,
relieve_pointer VARCHAR(100) NULL,
is_entry_point TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'money enters the chain here',
is_terminal TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'money has reached its resting place',
is_branch TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'an alternative outcome (bounced, written off) rather than the happy path',
expected_clearing_days SMALLINT UNSIGNED NULL COMMENT 'how long money may sit here before it is flagged as stuck',
is_active TINYINT(1) NOT NULL DEFAULT 1,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_chain_step (chain_id, step_no),
KEY idx_chain_step_event (trigger_event, is_active),
CONSTRAINT fk_chain_step_chain FOREIGN KEY (chain_id) REFERENCES posting_chains(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── Hop log ─────────────────────────────────────────────────────
//
// One row per hop actually posted. The reconciliation screen reads the
// ledger itself for balances — this table answers the other question:
// which document made this hop, when, and did it succeed. Without it a
// failed hop is invisible: the money simply stays parked and nobody
// knows whether it was never settled or the posting blew up.
$db->raw("
CREATE TABLE IF NOT EXISTS posting_chain_hops (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
chain_id BIGINT UNSIGNED NOT NULL,
step_id BIGINT UNSIGNED NOT NULL,
step_no SMALLINT UNSIGNED NOT NULL,
journal_entry_id BIGINT UNSIGNED NULL,
amount DECIMAL(18,2) NOT NULL DEFAULT 0.00,
parked_account_id BIGINT UNSIGNED NULL,
relieved_account_id BIGINT UNSIGNED NULL,
reference_type VARCHAR(50) NULL,
reference_id BIGINT UNSIGNED NULL,
reference_number VARCHAR(60) NULL,
treasury_id BIGINT UNSIGNED NULL,
branch_id BIGINT UNSIGNED NULL,
outcome ENUM('posted','failed','skipped') NOT NULL DEFAULT 'posted',
message VARCHAR(500) NULL,
posted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
KEY idx_hop_chain (chain_id, step_no, posted_at),
KEY idx_hop_ref (reference_type, reference_id),
KEY idx_hop_outcome (outcome, posted_at),
CONSTRAINT fk_hop_chain FOREIGN KEY (chain_id) REFERENCES posting_chains(id) ON DELETE CASCADE,
CONSTRAINT fk_hop_step FOREIGN KEY (step_id) REFERENCES posting_chain_steps(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
// ── Per-safe GL account ─────────────────────────────────────────
// A safe with no account of its own cannot be a clearing step: the
// settlement would credit an account the collection never debited.
$col = $db->selectOne(
"SELECT 1 AS ok FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'treasuries' AND column_name = 'gl_account_id'"
);
if (!$col) {
$db->raw("
ALTER TABLE treasuries
ADD COLUMN gl_account_id BIGINT UNSIGNED NULL COMMENT 'dedicated cash account for this safe' AFTER account_code,
ADD KEY idx_treasury_gl (gl_account_id)
");
}
// Backfill from the code each safe already carries, so nothing changes
// behaviour on this migration alone. Phase_108_002 is what re-points the
// safes that are aimed at the wrong account.
$db->raw("
UPDATE treasuries t
JOIN chart_of_accounts a ON a.account_code = t.account_code AND a.is_archived = 0
SET t.gl_account_id = a.id
WHERE t.gl_account_id IS NULL
");
},
'down' => static function (Database $db): void {
$db->raw("DROP TABLE IF EXISTS posting_chain_hops");
$db->raw("DROP TABLE IF EXISTS posting_chain_steps");
$db->raw("DROP TABLE IF EXISTS posting_chains");
$col = $db->selectOne(
"SELECT 1 AS ok FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'treasuries' AND column_name = 'gl_account_id'"
);
if ($col) {
$db->raw("ALTER TABLE treasuries DROP KEY idx_treasury_gl, DROP COLUMN gl_account_id");
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Give every safe its own cash account.
*
* The sub-treasuries were pointed at the foreign-currency cash boxes:
*
* خزنة الأنشطة الرياضية → 12060102 الصندوق بالدولار
* خزنة العضويات → 12060103 الصندوق باليورو (later re-pointed to 12060101)
*
* so every EGP note taken at those desks was booked into a USD or EUR box. The
* live ledger carries 63,289 in the dollar box and 909,502 in the euro box, all
* of it `reference_type = 'payment'` — not one piastre of genuine foreign cash.
*
* That is not a reporting nuisance; it is why the settlement chain could never
* close. A settlement credits "the sub-treasury account" and debits main cash,
* but the collection had debited a currency box, so the credit landed on an
* account that was never charged.
*
* This migration creates a real account per safe under 120601 النقدية بالصندوق
* and re-points the safes at them. It deliberately does NOT touch posted journal
* entries: rewriting history is not a correction, it is a forgery. The 972,791
* already sitting in the currency boxes is moved by a reclassification entry that
* finance reviews and posts from the chain screen — TreasuryAccountService::
* previewReclassification() / postReclassification().
*
* Idempotent: re-running creates nothing and re-points nothing.
*/
return [
'up' => static function (Database $db): void {
$parent = $db->selectOne(
"SELECT id, account_type, account_nature FROM chart_of_accounts
WHERE account_code = '120601' AND is_archived = 0"
);
if (!$parent) {
// No cash-box group in this chart — nothing sane to hang the safes off.
return;
}
$now = date('Y-m-d H:i:s');
// Next free 8-digit code under 120601, so this keeps working when the
// club adds a fourth and fifth safe years from now.
$nextCode = static function () use ($db): string {
$row = $db->selectOne(
"SELECT MAX(CAST(account_code AS UNSIGNED)) AS mx FROM chart_of_accounts
WHERE account_code LIKE '120601__' AND CHAR_LENGTH(account_code) = 8"
);
$mx = (int) ($row['mx'] ?? 12060100);
return (string) ($mx + 1);
};
$safes = $db->select(
"SELECT id, code, name_ar, name_en, type, account_code, gl_account_id
FROM treasuries ORDER BY type DESC, id ASC"
);
foreach ($safes as $safe) {
// The main safe IS the EGP cash box — it needs no account of its own,
// and inventing one would orphan every entry ever posted to 12060101.
if (($safe['type'] ?? '') === 'main') {
if (empty($safe['gl_account_id'])) {
$main = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '12060101' AND is_archived = 0"
);
if ($main) {
$db->update('treasuries', [
'account_code' => '12060101',
'gl_account_id' => (int) $main['id'],
'updated_at' => $now,
], '`id` = ?', [(int) $safe['id']]);
}
}
continue;
}
// Already on a dedicated account of its own? Leave it alone.
$current = !empty($safe['gl_account_id'])
? $db->selectOne(
"SELECT id, account_code FROM chart_of_accounts WHERE id = ?",
[(int) $safe['gl_account_id']]
)
: null;
$sharedOrCurrency = $current === null
|| \in_array($current['account_code'], ['12060101', '12060102', '12060103'], true);
if (!$sharedOrCurrency) {
continue;
}
// One account per safe, named after the safe so the trial balance
// reads like the building: "خزنة العضويات الفرعية", not "الصندوق باليورو".
$existing = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE parent_id = ? AND name_ar = ? AND is_archived = 0",
[(int) $parent['id'], $safe['name_ar']]
);
if ($existing) {
$accountId = (int) $existing['id'];
$accountCode = (string) ($db->selectOne(
"SELECT account_code FROM chart_of_accounts WHERE id = ?",
[$accountId]
)['account_code'] ?? '');
} else {
$accountCode = $nextCode();
$accountId = $db->insert('chart_of_accounts', [
'account_code' => $accountCode,
'name_ar' => $safe['name_ar'],
'name_en' => $safe['name_en'] ?: ($safe['code'] . ' Cash'),
'account_type' => $parent['account_type'] ?: 'asset',
'account_nature' => $parent['account_nature'] ?: 'debit',
'parent_id' => (int) $parent['id'],
'level' => 5,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'description_ar' => 'حساب النقدية الخاص بـ' . $safe['name_ar']
. ' — الفلوس اللي في الخزنة دي بالذات، قبل ما تتسوّى للخزنة الرئيسية.',
'opening_balance' => '0.00',
'current_balance' => '0.00',
'currency' => 'EGP',
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
$db->update('treasuries', [
'account_code' => $accountCode,
'gl_account_id' => $accountId,
'updated_at' => $now,
], '`id` = ?', [(int) $safe['id']]);
}
// Two pointers used to answer "which account holds this safe's cash":
// `treasury:sub_cash` for every sub-safe in the club, and
// `treasury:main_cash` for the main one. One answer for many safes is the
// question that has no right answer once there is more than one till, and
// having a second place that defines it is how the settlement and the
// collection came to disagree in the first place.
//
// treasuries.gl_account_id is the single answer now, and the chain reads
// it per document. Nothing in the code references either pointer any more
// (verified by grep), so retire both rather than leave dead configuration
// that reads as live on the mapping screen.
$retired = "'treasury:sub_cash', 'treasury:main_cash'";
$db->raw("
UPDATE revenue_posting_rules r
JOIN revenue_streams s ON s.id = r.stream_id
SET r.status = 'superseded',
r.effective_to = CURDATE(),
r.notes = CONCAT(COALESCE(r.notes,''), ' — أُلغيت: كل خزنة بقى ليها حساب خاص بيها، وسلسلة «دورة النقدية» بتحدده من المستند.'),
r.updated_at = NOW()
WHERE s.stream_code IN ({$retired}) AND r.status = 'active'
");
$db->raw("
UPDATE revenue_streams
SET is_active = 0,
notes = 'ملغي — كل خزنة بقى ليها حسابها الخاص في جدول الخزائن، وسلسلة «دورة النقدية» بتحدد حساب كل حركة من المستند نفسه.',
updated_at = NOW()
WHERE stream_code IN ({$retired})
");
},
'down' => static function (Database $db): void {
// Point the safes back at their codes; leave the accounts in place —
// dropping an account that may already carry postings loses data.
$db->raw("
UPDATE treasuries t
JOIN chart_of_accounts a ON a.id = t.gl_account_id
SET t.account_code = a.account_code
WHERE t.gl_account_id IS NOT NULL
");
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The record of what the ledger has already been told.
*
* Accruals need an idempotency key, and the receivable subledger cannot be it:
* `accounts_receivable.member_id` is NOT NULL, so an obligation owed by anyone
* who is not a member — an institution renting a court, an academy holding a
* deposit, a non-member player — has nowhere to live there. Keying on it would
* mean those accruals never register as done and get posted again on every run,
* inflating both the receivable and the income a little more each night.
*
* So this table is the authority: one row per (stream, document), carrying the
* amount the general ledger currently believes is owed. The receivable subledger
* stays as the member-facing view, derived from the same runs.
*
* It also makes accruals restatable rather than only creatable. A late fine
* recalculated from 50 to 75 posts a delta of 25 and updates the row to 75 —
* which is the whole reason the accrual runners can be a nightly cron instead of
* a once-a-year ritual nobody dares repeat.
*/
return [
'up' => static function (Database $db): void {
$db->raw("
CREATE TABLE IF NOT EXISTS posting_accruals (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
stream_code VARCHAR(100) NOT NULL COMMENT 'the revenue_streams code this obligation accrues under',
document_type VARCHAR(50) NOT NULL COMMENT 'subscription, sa_booking, rental_invoice ...',
document_id BIGINT UNSIGNED NOT NULL,
document_number VARCHAR(60) NULL,
accrued_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 'what the GL currently believes is owed',
settled_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT 'what has since been collected or paid',
member_id BIGINT UNSIGNED NULL COMMENT 'when the counterparty is a member; NULL for institutions and non-members',
counterparty_name VARCHAR(300) NULL COMMENT 'so a non-member obligation is still identifiable',
journal_entry_id BIGINT UNSIGNED NULL COMMENT 'the entry that last moved this accrual',
document_date DATE NULL,
due_date DATE NULL,
branch_id BIGINT UNSIGNED NULL,
status ENUM('open','settled','reversed','cancelled') NOT NULL DEFAULT 'open',
notes VARCHAR(500) NULL,
first_accrued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_accrual_document (document_type, document_id),
KEY idx_accrual_stream (stream_code, status),
KEY idx_accrual_member (member_id, status),
KEY idx_accrual_due (due_date, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
");
},
'down' => static function (Database $db): void {
$db->raw("DROP TABLE IF EXISTS posting_accruals");
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* A receivable account for people who are not members.
*
* The only detailed debtor account in the chart is «١٢٠٣٠١٠٠٤ أعضاء النادي
* (مدينون)». That is right for a subscription and wrong for most of what the
* accrual scanner is about to book: a school hiring a pitch, a walk-in booking a
* court, a non-member player on a monthly activity subscription. Posting those
* against club members would make the member receivable report — the one used to
* chase members and to drop them for non-payment — claim money from people who
* do not owe it.
*
* So: one sibling account under the same «العملاء» group, for activity and
* booking debtors. Rentals and academy rent already have «١٢٠٣٠١٠٠٣ وحدات
* تجارية», which is where commercial tenants belong, so they are left there.
*
* Idempotent.
*/
return [
'up' => static function (Database $db): void {
$parent = $db->selectOne(
"SELECT id, account_type, account_nature FROM chart_of_accounts
WHERE account_code = '120301' AND is_archived = 0"
);
if (!$parent) {
return;
}
$exists = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '120301006'"
);
if ($exists) {
return;
}
$now = date('Y-m-d H:i:s');
$db->insert('chart_of_accounts', [
'account_code' => '120301006',
'name_ar' => 'مدينو الأنشطة والحجوزات (غير الأعضاء)',
'name_en' => 'Activity & Booking Receivables (non-members)',
'account_type' => $parent['account_type'] ?: 'asset',
'account_nature' => $parent['account_nature'] ?: 'debit',
'parent_id' => (int) $parent['id'],
'level' => 5,
'level_name' => 'جزئي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'description_ar' => 'المستحق على غير الأعضاء — مدارس وشركات ولاعبين مش أعضاء — '
. 'من حجوزات الملاعب واشتراكات النشاط واللوكرات. '
. 'منفصل عن حساب مديونية الأعضاء عشان تقرير مديونية الأعضاء يفضل صح.',
'opening_balance' => '0.00',
'current_balance' => '0.00',
'currency' => 'EGP',
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
},
'down' => static function (Database $db): void {
// Only if nothing was ever posted to it — an account with history stays.
$acc = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = '120301006'");
if (!$acc) {
return;
}
$used = $db->selectOne(
"SELECT 1 AS n FROM journal_entry_lines WHERE account_id = ? LIMIT 1",
[(int) $acc['id']]
);
if (!$used) {
$db->query("DELETE FROM chart_of_accounts WHERE id = ?", [(int) $acc['id']]);
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The account that sits between receiving goods and being invoiced for them.
*
* Without it there is no way to book a goods receipt at all. The vendor invoice
* already posts Dr Inventory / Cr Suppliers, so a receipt that also posted
* Dr Inventory would count the same stock twice — which is exactly why the
* receipt was left unposted and the warehouse has been holding value the ledger
* never saw.
*
* With a clearing account the three-way match works the way it is supposed to:
*
* goods received Dr Inventory Cr Goods received not invoiced
* invoice approved Dr GRNI Cr Suppliers
*
* The clearing account nets to nil once both halves land, and whatever is left
* sitting in it is stock the club has taken in and not yet been billed for —
* a number the finance team should be able to see and chase.
*
* Idempotent.
*/
return [
'up' => static function (Database $db): void {
$exists = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = '230817'");
if ($exists) {
return;
}
$parent = $db->selectOne(
"SELECT id, account_type, account_nature FROM chart_of_accounts
WHERE account_code = '2308' AND is_archived = 0"
);
if (!$parent) {
return;
}
$now = date('Y-m-d H:i:s');
$db->insert('chart_of_accounts', [
'account_code' => '230817',
'name_ar' => 'بضاعة مستلمة لم ترد فاتورتها',
'name_en' => 'Goods Received Not Invoiced',
'account_type' => $parent['account_type'] ?: 'liability',
'account_nature' => $parent['account_nature'] ?: 'credit',
'parent_id' => (int) $parent['id'],
'level' => 4,
'level_name' => 'فرعي',
'is_header' => 0,
'is_active' => 1,
'is_system' => 1,
'description_ar' => 'بضاعة دخلت المخزن وفاتورة المورد لسه ما وصلتش. '
. 'بيتقفل لما الفاتورة تتعتمد. اللي فاضل فيه = بضاعة عندنا '
. 'ولسه محدش حاسبنا عليها.',
'opening_balance' => '0.00',
'current_balance' => '0.00',
'currency' => 'EGP',
'is_archived' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
},
'down' => static function (Database $db): void {
$acc = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = '230817'");
if (!$acc) {
return;
}
$used = $db->selectOne(
"SELECT 1 AS n FROM journal_entry_lines WHERE account_id = ? LIMIT 1",
[(int) $acc['id']]
);
if (!$used) {
$db->query("DELETE FROM chart_of_accounts WHERE id = ?", [(int) $acc['id']]);
}
},
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* The four routes money actually takes through this club.
*
* Each is written the way finance describes it out loud, and each step says two
* things: where it leaves the money, and which earlier step it clears. Nothing
* here invents a posting — the treasury chain is the only one the engine posts
* itself. The other three describe hops that dedicated services already perform,
* so that the chain screen can show the whole route, the health check can prove
* both ends of every hop agree, and the aging report can chase money that stops
* moving.
*
* Idempotent: matched on chain_code and step_no, so re-running updates the
* definition in place rather than duplicating it.
*/
return static function (Database $db): void {
$chains = [
// ── Cash: desk → safe → main safe → bank ────────────────────────
[
'chain_code' => 'treasury:cash_lifecycle',
'name_ar' => 'دورة النقدية — من الكاشير للبنك',
'name_en' => 'Cash lifecycle',
'domain' => 'treasury',
'description_ar' => 'الفلوس اللي بتتحصّل على المكتب مش بتروح البنك على طول. بتقعد في حساب '
. 'الخزنة اللي حصّلت، وبعدين التسوية بتنقلها للخزنة الرئيسية، وبعدين الإيداع '
. 'بينقلها للبنك. كل خطوة لازم تفضّي الحساب اللي قبلها — لو فضل فيه رصيد، '
. 'يبقى فيه فلوس واقفة في النص.',
'steps' => [
[
'step_no' => 1,
'name_ar' => 'التحصيل في الخزنة',
'description_ar' => 'الكاشير بيحصّل نقدي. المبلغ بينزل في حساب الخزنة دي بالذات، '
. 'مش في حساب نقدية عام — عشان التسوية تعرف تفضّيه بعد كده.',
'trigger_event' => 'payment.completed',
'hop_type' => 'transfer',
'posted_by_chain'=> 0, // the allocation engine posts the collection
'parks_resolver' => 'treasury_of_txn',
'relieve_resolver' => 'none',
'is_entry_point' => 1,
'expected_clearing_days' => 1,
],
[
'step_no' => 2,
'name_ar' => 'تسوية الوردية للخزنة الرئيسية',
'description_ar' => 'آخر الوردية الكاشير بيسلّم، والخزنة الرئيسية بتستلم. القيد بيفضّي '
. 'حساب الخزنة الفرعية بالظبط وينقله للرئيسية.',
'trigger_event' => 'treasury.settlement.received',
'hop_type' => 'transfer',
'posted_by_chain'=> 1,
'parks_resolver' => 'treasury_target',
'relieves_step_no' => 1,
'relieve_resolver' => 'inherit',
'expected_clearing_days' => 2,
],
[
'step_no' => 3,
'name_ar' => 'الإيداع البنكي',
'description_ar' => 'الفلوس بتروح البنك ويتأكد الإيداع. الخزنة اللي في إذن الإيداع هي '
. 'اللي بتتفضّى — مش بالضرورة الرئيسية، عشان الفرع اللي بيودّع من '
. 'خزنته يفضّي خزنته هو.',
'trigger_event' => 'treasury.deposit.confirmed',
'hop_type' => 'transfer',
'posted_by_chain'=> 1,
'parks_resolver' => 'bank_of_txn',
'relieves_step_no' => 2,
'relieve_resolver' => 'treasury_of_txn',
'is_terminal' => 1,
],
],
],
// ── Cheques: in hand → under collection → bank, or back ─────────
[
'chain_code' => 'instrument:cheque_receivable',
'name_ar' => 'دورة الشيكات المستلمة',
'name_en' => 'Received cheque lifecycle',
'domain' => 'instrument',
'description_ar' => 'الشيك مش فلوس ساعة ما تستلمه — هو وعد بالدفع. بيفضل ورقة قبض لحد ما '
. 'يتودّع برسم التحصيل، وساعتها بيبقى «تحت التحصيل»، ولما البنك يدفع بيبقى '
. 'فلوس فعلًا. لو ارتد، الدين بيرجع على العضو.',
'steps' => [
[
'step_no' => 1,
'name_ar' => 'استلام الشيك (ورقة قبض)',
'description_ar' => 'الشيك في إيد النادي. لسه مش فلوس في البنك.',
'trigger_event' => 'payment.completed',
'hop_type' => 'transfer',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'instrument:notes_receivable',
'relieve_resolver' => 'none',
'is_entry_point' => 1,
'expected_clearing_days' => 3,
],
[
'step_no' => 2,
'name_ar' => 'إيداع الشيك برسم التحصيل',
'description_ar' => 'الشيك راح للبنك عشان يحصّله. بيتنقل من أوراق القبض لحساب '
. '«شيكات تحت التحصيل».',
'hop_type' => 'transfer',
'posted_by_chain'=> 0, // InstrumentPostingService posts this leg
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'instrument:under_collection_account',
'relieves_step_no' => 1,
'relieve_resolver' => 'inherit',
'expected_clearing_days' => 7,
],
[
'step_no' => 3,
'name_ar' => 'البنك حصّل الشيك',
'description_ar' => 'دلوقتي بس بقت فلوس. حساب «تحت التحصيل» بيتفضّى والبنك بيتحمّل.',
'hop_type' => 'transfer',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'instrument:bank_account',
'relieves_step_no' => 2,
'relieve_resolver' => 'inherit',
'is_terminal' => 1,
],
[
'step_no' => 4,
'name_ar' => 'الشيك ارتد',
'description_ar' => 'البنك ما دفعش. حساب «تحت التحصيل» بيتفضّى، والدين بيرجع على '
. 'العضو في حساب الشيكات المرتدة. مصاريف الارتداد بتتسجّل في قيد '
. 'لوحده عشان ينفع تتشال من غير ما تلمس الدين.',
'hop_type' => 'transfer',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'instrument:bounced_receivable',
'relieves_step_no' => 2,
'relieve_resolver' => 'inherit',
'is_branch' => 1,
'expected_clearing_days' => 30,
],
],
],
// ── Member receivables: claim → cash, or written off ────────────
[
'chain_code' => 'receivable:member',
'name_ar' => 'دورة المديونية على الأعضاء',
'name_en' => 'Member receivable lifecycle',
'domain' => 'receivable',
'description_ar' => 'لما النادي يستحق فلوس على عضو، الإيراد بيتسجّل والدين بيتقيّد عليه. '
. 'التحصيل بعد كده مش إيراد تاني — هو تبديل دين بفلوس. لو الدين اتسقط، '
. 'بيروح ديون معدومة.',
'steps' => [
[
'step_no' => 1,
'name_ar' => 'استحقاق على العضو',
'description_ar' => 'النادي سجّل الإيراد وقيّد الدين على العضو.',
'hop_type' => 'expand',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'ar:control@accrual',
'relieve_resolver' => 'none',
'is_entry_point' => 1,
'expected_clearing_days' => 30,
],
[
'step_no' => 2,
'name_ar' => 'التحصيل من العضو',
'description_ar' => 'الفلوس دخلت الخزنة والدين اتفضّى. مفيش إيراد بيتسجّل هنا تاني — '
. 'الإيراد اتسجّل وقت الاستحقاق.',
'trigger_event' => 'payment.completed',
'hop_type' => 'transfer',
'posted_by_chain'=> 0,
'parks_resolver' => 'treasury_of_txn',
'relieves_step_no' => 1,
'relieve_resolver' => 'inherit',
'is_terminal' => 1,
],
[
'step_no' => 3,
'name_ar' => 'إسقاط الدين',
'description_ar' => 'الدين اتقرر إنه مش هيتحصّل. بيتشال من على العضو ويروح مصروف '
. 'ديون معدومة.',
'trigger_event' => 'member.dropped',
'hop_type' => 'transfer',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'member:writeoff@writeoff',
'relieves_step_no' => 1,
'relieve_resolver' => 'inherit',
'is_branch' => 1,
'is_terminal' => 1,
],
],
],
// ── Vendor payables: invoice → payment ──────────────────────────
[
'chain_code' => 'payable:vendor',
'name_ar' => 'دورة مستحقات الموردين',
'name_en' => 'Vendor payable lifecycle',
'domain' => 'payable',
'description_ar' => 'فاتورة المورد بتتقيّد أول ما تتعتمد، حتى لو السداد بعدها بشهر. السداد '
. 'بعد كده مش مصروف تاني — هو إطفاء للالتزام: الدين بينقص والفلوس بتنقص.',
'steps' => [
[
'step_no' => 1,
'name_ar' => 'اعتماد فاتورة المورد',
'description_ar' => 'المصروف أو المخزون بيتحمّل، والالتزام بيتقيّد للمورد.',
'trigger_event' => 'procurement.invoice_approved',
'hop_type' => 'expand',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'procurement:payable@accrual',
'relieve_resolver' => 'none',
'is_entry_point' => 1,
'expected_clearing_days' => 30,
],
[
'step_no' => 2,
'name_ar' => 'سداد المورد',
'description_ar' => 'الالتزام بيتفضّى والفلوس بتطلع من البنك. الاتنين بينقصوا — '
. 'عشان كده نوع الحركة «سداد» مش «نقل».',
'trigger_event' => 'procurement.payment_completed',
'hop_type' => 'contract',
'posted_by_chain'=> 0,
'parks_resolver' => 'stream_pointer',
'parks_pointer' => 'treasury:method_bank_transfer',
'relieves_step_no' => 1,
'relieve_resolver' => 'inherit',
'is_terminal' => 1,
],
],
],
];
$now = date('Y-m-d H:i:s');
foreach ($chains as $c) {
$existing = $db->selectOne("SELECT id FROM posting_chains WHERE chain_code = ?", [$c['chain_code']]);
$payload = [
'chain_code' => $c['chain_code'],
'name_ar' => $c['name_ar'],
'name_en' => $c['name_en'],
'domain' => $c['domain'],
'description_ar' => $c['description_ar'],
'is_system' => 1,
'is_active' => 1,
'updated_at' => $now,
];
if ($existing) {
$chainId = (int) $existing['id'];
$db->update('posting_chains', $payload, '`id` = ?', [$chainId]);
} else {
$chainId = $db->insert('posting_chains', $payload + ['created_at' => $now]);
}
foreach ($c['steps'] as $s) {
$step = [
'chain_id' => $chainId,
'step_no' => $s['step_no'],
'name_ar' => $s['name_ar'],
'description_ar' => $s['description_ar'] ?? null,
'trigger_event' => $s['trigger_event'] ?? null,
'stream_code' => $s['stream_code'] ?? null,
'stage' => $s['stage'] ?? null,
'hop_type' => $s['hop_type'] ?? 'transfer',
'posted_by_chain' => (int) ($s['posted_by_chain'] ?? 1),
'parks_resolver' => $s['parks_resolver'] ?? 'none',
'parks_account_id' => $s['parks_account_id'] ?? null,
'parks_pointer' => $s['parks_pointer'] ?? null,
'relieves_step_no' => $s['relieves_step_no'] ?? null,
'relieve_resolver' => $s['relieve_resolver'] ?? 'inherit',
'relieve_account_id' => $s['relieve_account_id'] ?? null,
'relieve_pointer' => $s['relieve_pointer'] ?? null,
'is_entry_point' => (int) ($s['is_entry_point'] ?? 0),
'is_terminal' => (int) ($s['is_terminal'] ?? 0),
'is_branch' => (int) ($s['is_branch'] ?? 0),
'expected_clearing_days' => $s['expected_clearing_days'] ?? null,
'is_active' => 1,
'updated_at' => $now,
];
$existingStep = $db->selectOne(
"SELECT id FROM posting_chain_steps WHERE chain_id = ? AND step_no = ?",
[$chainId, $s['step_no']]
);
if ($existingStep) {
$db->update('posting_chain_steps', $step, '`id` = ?', [(int) $existingStep['id']]);
} else {
$db->insert('posting_chain_steps', $step + ['created_at' => $now]);
}
}
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Grant the posting-chain permissions.
*
* A permission that nobody holds is a screen that exists for the super admin
* only, which is the same as not shipping it. `accountant` is the role that
* already carries every comparable accounting permission — the chart of
* accounts, the journal, the treasury report, the revenue mapping — so it gets
* both view and manage here.
*
* `auditor` gets view alone, deliberately: the whole point of the money-in-
* transit screen is that someone independent can see a clearing account that
* has stopped draining. Being able to look must not carry the ability to post
* the reclassification entry.
*
* Idempotent — an existing grant is left alone rather than duplicated.
*/
return static function (Database $db): void {
$grants = [
'accountant' => [
'accounting.chains.view', 'accounting.chains.manage',
'accounting.accruals.view', 'accounting.accruals.manage',
],
'auditor' => ['accounting.chains.view', 'accounting.accruals.view'],
];
$now = date('Y-m-d H:i:s');
foreach ($grants as $roleCode => $permissions) {
$role = $db->selectOne("SELECT id FROM roles WHERE role_code = ?", [$roleCode]);
if (!$role) {
continue; // role not present in this deployment
}
foreach ($permissions as $key) {
$exists = $db->selectOne(
"SELECT id FROM role_permissions WHERE role_id = ? AND permission_key = ?",
[(int) $role['id'], $key]
);
if ($exists) {
continue;
}
$db->insert('role_permissions', [
'role_id' => (int) $role['id'],
'permission_key' => $key,
'granted_at' => $now,
]);
}
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Accrual rules for every obligation the scanner books.
*
* Wiring an event without a rule changes nothing — PostingRouter reports the
* stream as unconfigured and the caller carries on as before. So the wiring and
* the mapping ship together, and the club gets a working default it can adjust
* from the allocation screen rather than a screen full of things to set up.
*
* Two principles decided every account below:
*
* 1. The accrual credits the SAME revenue account the collection already
* credits. If the accrual booked a subscription to one account and the
* payment credited another, the receivable would never clear and the income
* would appear twice under different names. Where the existing collection
* mapping looks wrong — annual member subscriptions currently credit
* «اكاديمية البادل» — it is matched anyway and flagged, because making them
* agree is correct and re-pointing a thousand historic postings is a finance
* decision, not a migration.
*
* 2. A deposit is not income. Money the club must give back at the end of a
* contract is a liability; booking it as revenue would overstate the result
* by the whole amount and hide the obligation.
*
* Idempotent — an existing active accrual rule for a stream is left alone, so
* re-running never overwrites what finance has since adjusted.
*/
return static function (Database $db): void {
$accountId = static function (string $code) use ($db): ?int {
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_active = 1 AND is_header = 0",
[$code]
);
return $row ? (int) $row['id'] : null;
};
// stream => [counter account, revenue/liability account, line type, label]
$rules = [
// ── Member obligations — debtor is a club member ────────────
'subscription:annual_accrual' => [
'counter' => '120301004', 'account' => '410201', 'type' => 'revenue',
'name' => 'استحقاق الاشتراك السنوي',
'note' => 'بيقيّد المستحق على العضو وقت التوليد. الحساب الدائن هو نفس حساب '
. 'التحصيل عشان الاتنين يتقفلوا على بعض.',
],
'payment:development_fee' => [
'counter' => '120301004', 'account' => '410202', 'type' => 'revenue',
'name' => 'استحقاق رسم التنمية',
'note' => 'رسم التنمية بيتحصّل لوحده كنوع دفع منفصل، فبيتقيّد لوحده كمان.',
],
'subscription:late_fee' => [
'counter' => '120301004', 'account' => '410512', 'type' => 'revenue',
'name' => 'استحقاق غرامة تأخير الاشتراك',
'note' => 'الغرامة بتكبر مع الوقت، والماسح بيرحّل الفرق بس مش المبلغ كامل تاني.',
],
// ── Activity & booking obligations — debtor is often NOT a member ──
'sa:hourly_booking' => [
'counter' => '120301006', 'account' => '410523', 'type' => 'revenue',
'name' => 'استحقاق حجز ملعب',
'note' => 'الحاجز ساعات مدرسة أو شركة مش عضو، عشان كده المدين حساب '
. 'مدينو الأنشطة مش حساب الأعضاء.',
],
'sa:monthly_subscription' => [
'counter' => '120301006', 'account' => '410515', 'type' => 'revenue',
'name' => 'استحقاق اشتراك النشاط الرياضي',
'note' => 'نفس حساب تحصيل اشتراك النشاط الحالي.',
],
'sa:locker_rental' => [
'counter' => '120301006', 'account' => '410528', 'type' => 'revenue',
'name' => 'استحقاق إيجار لوكر',
'note' => 'حساب «لوكرات» موجود في الدليل ومخصص للغرض ده بالظبط.',
],
'facility:reservation' => [
'counter' => '120301006', 'account' => '410523', 'type' => 'revenue',
'name' => 'استحقاق حجز مرفق',
'note' => 'نفس حساب حجز الملاعب.',
],
// ── Commercial tenants ──────────────────────────────────────
'rental:monthly_invoice' => [
'counter' => '120301003', 'account' => '410521', 'type' => 'revenue',
'name' => 'استحقاق فاتورة إيجار محل',
'note' => 'المستأجر التجاري ليه حسابه في «وحدات تجارية».',
],
'academy:contract_rent' => [
'counter' => '120301003', 'account' => '410521', 'type' => 'revenue',
'name' => 'استحقاق إيجار أكاديمية',
'note' => 'بيتقيّد بالشهر المنقضي بس — مش العقد كله مرة واحدة.',
],
// ── A deposit is a liability, not income ────────────────────
'academy:contract_deposit' => [
'counter' => '12060101', 'account' => '230801', 'type' => 'passthrough',
'name' => 'تأمين عقد أكاديمية (التزام)',
'note' => 'التأمين فلوس النادي ماسكها ولازم يرجّعها آخر العقد — التزام مش إيراد. '
. 'الطرف المدين النقدية لأن التأمين متعلّم إنه اتحصّل. '
. 'المفروض يتحصّل بإيصال عادي، وساعتها الطرف ده هيبقى دقيق أكتر.',
],
];
$now = date('Y-m-d H:i:s');
foreach ($rules as $streamCode => $spec) {
$stream = $db->selectOne(
"SELECT id, name_ar FROM revenue_streams WHERE stream_code = ?",
[$streamCode]
);
if (!$stream) {
continue;
}
$streamId = (int) $stream['id'];
// Never overwrite a rule finance already has in place.
$existing = $db->selectOne(
"SELECT id FROM revenue_posting_rules
WHERE stream_id = ? AND stage = 'accrual' AND status = 'active'",
[$streamId]
);
if ($existing) {
continue;
}
$counterId = $accountId($spec['counter']);
$creditId = $accountId($spec['account']);
if ($counterId === null || $creditId === null) {
continue; // chart differs on this deployment — leave it to the screen
}
$maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = 'accrual'",
[$streamId]
);
$version = $maxRow && $maxRow['v'] !== null ? ((int) $maxRow['v']) + 1 : 1;
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => $version,
'stage' => 'accrual',
'direction' => 'inflow',
'name_ar' => $spec['name'],
'debit_account_id' => $counterId,
'debit_source' => $spec['counter'] === '12060101' ? 'fixed_account' : 'accounts_receivable',
'status' => 'active',
'effective_from' => date('Y-m-d'),
'notes' => $spec['note'],
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => $spec['type'],
'allocation_method' => 'remainder',
'account_id' => $creditId,
'description_ar' => $spec['name'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// The scanner reconciles these; the wiring note that said "needs a developer"
// is no longer true, and leaving it there would keep sending finance to look
// for code that is now written.
$scanned = [
'subscription:annual_accrual', 'subscription:late_fee', 'sa:hourly_booking',
'sa:monthly_subscription', 'sa:locker_rental', 'facility:reservation',
'rental:monthly_invoice', 'academy:contract_rent', 'academy:contract_deposit',
];
$placeholders = implode(',', array_fill(0, count($scanned), '?'));
$db->query(
"UPDATE revenue_streams
SET wiring_status = 'dispatches',
wiring_note = 'بيتقيّد تلقائيًا من ماسح الاستحقاقات — بيمشي كل ليلة وبيقيّد اللي لسه ما اتقيّدش.',
updated_at = NOW()
WHERE stream_code IN ({$placeholders})",
$scanned
);
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Posting rules for the operations that move money out.
*
* Same principle as the accrual rules: a listener without a rule posts nothing,
* so the wiring and the mapping ship together. Every account below is the one
* the Egyptian chart already carries for the purpose — none of them is invented,
* and all of them are re-pointable from the allocation screen.
*
* The distinctions that matter here are about what each payment IS:
*
* a staff loan is an ASSET, not a cost — the club expects it back
* goods received are inventory against a CLEARING account, not the supplier
* coach fees are ACCRUED on approval, because the coaching is already done
*
* Idempotent — an existing active rule for the stage is left alone.
*/
return static function (Database $db): void {
$accountId = static function (string $code) use ($db): ?int {
$row = $db->selectOne(
"SELECT id FROM chart_of_accounts
WHERE account_code = ? AND is_archived = 0 AND is_active = 1 AND is_header = 0",
[$code]
);
return $row ? (int) $row['id'] : null;
};
$rules = [
// ── Money out of the treasury ───────────────────────────────
'hr:loan_disbursement' => [
'stage' => 'payment', 'direction' => 'outflow',
'counter_source' => 'auto_treasury', 'counter' => null,
'account' => '120402', 'type' => 'asset',
'name' => 'صرف سلفة موظف',
'note' => 'السلفة أصل مش مصروف — النادي مستنيها ترجع. '
. 'بتتخصم من المرتب على أقساط وبتنزل الحساب ده تاني.',
],
'hr:end_of_service' => [
'stage' => 'payment', 'direction' => 'outflow',
'counter_source' => 'auto_treasury', 'counter' => null,
'account' => '332102', 'type' => 'expense',
'name' => 'صرف مستحقات نهاية الخدمة',
'note' => 'أكبر دفعة فردية في الموارد البشرية، وكانت بتتصرف من غير أي قيد.',
],
// ── Accrued, because the service is already delivered ───────
'hr:coach_payment' => [
'stage' => 'accrual', 'direction' => 'outflow',
'counter_source' => 'fixed_account', 'counter' => '230810',
'account' => '310104', 'type' => 'expense',
'name' => 'مستحقات المدربين',
'note' => 'بيتقيّد وقت الاعتماد لأن التدريب اتعمل خلاص. '
. 'الانتظار للصرف كان بيرمي التكلفة على الشهر اللي الشيك اتصرف فيه.',
],
// ── Goods in, before the invoice ────────────────────────────
'inventory:goods_receipt' => [
'stage' => 'accrual', 'direction' => 'inflow',
'counter_source' => 'fixed_account', 'counter' => '120209',
'account' => '230817', 'type' => 'passthrough',
'name' => 'استلام بضاعة قبل الفاتورة',
'note' => 'المخزون بيزيد مقابل حساب وسيط، مش مقابل المورد. '
. 'الفاتورة لما تتعتمد بتقفل الحساب الوسيط وتقيّد المورد.',
],
// ── Stock count differences ─────────────────────────────────
'inventory:stock_variance' => [
'stage' => 'writeoff', 'direction' => 'outflow',
'counter_source' => 'fixed_account', 'counter' => '120209',
'account' => '331705', 'type' => 'writeoff',
'name' => 'عجز جرد',
'note' => 'العجز بينقص المخزون ويروح خسائر اضمحلال. '
. 'الزيادة بتتقيّد بالعكس على نفس الحسابين.',
],
// ── Asset disposal — the gain/loss pointer ──────────────────
'inventory:asset_disposal' => [
'stage' => 'writeoff', 'direction' => 'outflow',
'counter_source' => 'fixed_account', 'counter' => '120407',
'account' => '3320', 'type' => 'writeoff',
'name' => 'خسائر استبعاد الأصول',
'note' => 'مؤشر حساب — بيحدد حساب الخسارة اللي بينزل عليه فرق '
. 'حصيلة البيع عن القيمة الدفترية.',
],
// ── Tournament entry fees ───────────────────────────────────
'tournament:registration_fee' => [
'stage' => 'accrual', 'direction' => 'inflow',
'counter_source' => 'accounts_receivable', 'counter' => '120301006',
'account' => '410517', 'type' => 'revenue',
'name' => 'استحقاق رسم اشتراك بطولة',
'note' => 'حساب «أحداث رياضية» موجود ومخصص للغرض ده.',
],
];
$now = date('Y-m-d H:i:s');
foreach ($rules as $streamCode => $spec) {
$stream = $db->selectOne("SELECT id FROM revenue_streams WHERE stream_code = ?", [$streamCode]);
if (!$stream) {
continue;
}
$streamId = (int) $stream['id'];
$existing = $db->selectOne(
"SELECT id FROM revenue_posting_rules
WHERE stream_id = ? AND stage = ? AND status = 'active'",
[$streamId, $spec['stage']]
);
if ($existing) {
continue;
}
$creditId = $accountId($spec['account']);
if ($creditId === null) {
continue;
}
$counterId = $spec['counter'] !== null ? $accountId($spec['counter']) : null;
if ($spec['counter'] !== null && $counterId === null) {
continue;
}
$maxRow = $db->selectOne(
"SELECT MAX(version) AS v FROM revenue_posting_rules WHERE stream_id = ? AND stage = ?",
[$streamId, $spec['stage']]
);
$version = $maxRow && $maxRow['v'] !== null ? ((int) $maxRow['v']) + 1 : 1;
$ruleId = $db->insert('revenue_posting_rules', [
'stream_id' => $streamId,
'version' => $version,
'stage' => $spec['stage'],
'direction' => $spec['direction'],
'name_ar' => $spec['name'],
'debit_account_id' => $counterId,
'debit_source' => $spec['counter_source'],
'status' => 'active',
'effective_from' => date('Y-m-d'),
'notes' => $spec['note'],
'created_at' => $now,
'updated_at' => $now,
'activated_at' => $now,
]);
$db->insert('revenue_posting_rule_lines', [
'rule_id' => $ruleId,
'sort_order' => 1,
'line_type' => $spec['type'],
'allocation_method' => 'remainder',
'account_id' => $creditId,
'description_ar' => $spec['name'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
}
// These now have listeners on events that are actually dispatched.
$wired = [
'hr:loan_disbursement', 'hr:end_of_service', 'hr:coach_payment',
'inventory:goods_receipt', 'inventory:depreciation', 'inventory:stock_variance',
'inventory:asset_disposal', 'fine:waived', 'tournament:registration_fee',
];
$placeholders = implode(',', array_fill(0, count($wired), '?'));
$db->query(
"UPDATE revenue_streams
SET wiring_status = 'dispatches',
wiring_note = 'موصّل — فيه حدث بيترسل ومستمع بيرحّل القيد.',
updated_at = NOW()
WHERE stream_code IN ({$placeholders})",
$wired
);
// Honest about the ones that still cannot be booked. These are product gaps,
// not missing listeners: there is no amount, no counterparty, or no
// collection path at all, and posting a guess would be worse than the gap.
$blocked = [
'sa:pool_zone_booking' => 'مفيش سجل لمين دخل ولا هل دفع — الاستحقاق هيبقى تقدير مش مطالبة.',
'sa:player_card' => 'الجدول مفيهوش عمود مبلغ أصلًا — مفيش رسم مسجّل يتقيّد.',
'facility:pool_booking' => 'الكود بيسجّل كل حجز بصفر — المشكلة تسعير مش محاسبة.',
'facility:private_match' => 'المقدم بيتكتب في عمود من غير إيصال — مفيش مستند يتقيّد عليه.',
'academy:enrollment' => 'القيد مالوش رسوم — الإيراد بيتقيّد من اشتراك النشاط.',
'academy:settlement' => 'محرك التسويات بيقرا academy_contracts (فاضي) والعقود الحقيقية في '
. 'sa_academy_contracts — دي ميزة متكررة في موديولين، محتاجة قرار أي '
. 'جدول هو الأصل قبل أي ربط محاسبي.',
];
foreach ($blocked as $code => $note) {
$db->query(
"UPDATE revenue_streams SET wiring_status = 'needs_code', wiring_note = ?, updated_at = NOW()
WHERE stream_code = ?",
[$note, $code]
);
}
};
# الاستحقاقات — الفلوس اللي لينا وعلينا قبل ما حد يدفع
> **الغرض:** تفهم ليه ٢٤ مصدر إيراد كانوا «محتاجين كود»، وإيه اللي اتعمل فيهم، وإزاي
> النظام دلوقتي بيقيّد المستحق من غير ما يستنى حد يدفع — من غير ما يعدّ الإيراد مرتين.
---
## المشكلة في سطرين
النادي كان بيستحق فلوس والدفاتر ما تعرفش. اشتراك بيتولّد، ملعب بيتحجز، فاتورة إيجار
بتتعمل — والقيد ما بيتعملش إلا لو ولما حد يدفع.
النتيجة حاجتين، والاتنين غلط:
1. **النادي بيقلّل اللي ليه.** ٨٣٥ ألف جنيه مستحقات ما كانتش ظاهرة في الميزانية.
2. **الإيراد بينزل في الشهر الغلط.** اشتراك السنة المالية دي بيتحسب إيراد الشهر اللي
العضو دفع فيه، مش الشهر اللي استحق فيه.
---
## اللي اتعمل
### ماسح بدل أحداث
الطبيعي إن كل موديول يبعت حدث والمحاسبة تسمع. المشكلة إن ده هش:
- حدث ممكن ما يتبعتش أصلًا
- ممكن يتبعت باسم غير اللي المستمع مستنيه — **ده حصل فعلًا**: مستمع رواتب المدربين
كان مربوط على اسم حدث محدش بيبعته، وفضل كده شهور ومحدش واخد باله
- ممكن يتبعت قبل ما الترانزاكشن يتقفل
فبدل كده: **ماسح بيقرا الجداول نفسها كل ليلة**، ويقيّد اللي لسه ما اتقيّدش.
| الميزة | يعني إيه |
|---|---|
| **بيصلّح نفسه** | حدث ما اتبعتش؟ الجولة الجاية هتلقطه. |
| **بيلحق القديم** | قيّد المتأخرات اللي اتراكمت وإحنا مش موصّلين. |
| **آمن يتشغّل ألف مرة** | جدول `posting_accruals` فاكر اللي اتقال للدفاتر. |
### النص التاني: الإقفال بعد التحصيل
**دي أهم نقطة في الملف كله.**
التحصيل في النظام ده بيسجّل الإيراد على طول (`Dr نقدية / Cr إيراد`). فلو قيّدنا
الاستحقاق وسبناه، الإيراد هيتعدّ **مرتين**:
```
الاستحقاق مدين مدينون ١٠٠ دائن إيراد ١٠٠
التحصيل مدين نقدية ١٠٠ دائن إيراد ١٠٠ ← الإيراد بقى ٢٠٠
الإقفال مدين إيراد ١٠٠ دائن مدينون ١٠٠ ← رجع ١٠٠، والمدينون صفر
```
قيد الإقفال هو **المرآة بالظبط** لقيد الاستحقاق — بيتبني بإعادة تطبيق نفس القاعدة
وقلب كل سطر. فلو التقسيمة كانت مقسّمة على كذا حساب، بتتفك على نفس الحسابات.
وبيشتغل من جدول الاستحقاقات مش من مسار الدفع، فمستحيل يشتغل على فلوس ما اتقيّدتش،
ومستحيل يشتغل مرتين على نفس المستند.
---
## اللي اتوصّل (١٨ مصدر)
### مستحقات على الغير — الماسح بيقيّدها
| المصدر | الجداول | اللي اتقيّد |
|---|---|---|
| اشتراكات الأعضاء السنوية | `subscriptions` | ١٨٠٬٩٣٦ على ٥٧٧ مطالبة |
| غرامات تأخير الاشتراكات | `subscriptions.fine_amount` | ١٠٠٬٠٠٣ على ٣٨٦ |
| رسوم التنمية | `subscriptions.development_fee` | ٦٬٤٧٥ على ١٨٥ |
| حجوزات الملاعب | `sa_bookings` | ٧٤٬٨٨٤ على ٢٠ |
| اشتراكات النشاط الرياضي | `sa_subscriptions` | ١٠٤٬٧٦٩ على ١٣٥ |
| إيجار اللوكرات | `sa_locker_rentals` | ١٬٠٠٠ على ٢ |
| حجوزات المرافق | `reservations` | ٢٬١٠٠ على ٥ |
| فواتير إيجار المحلات | `rental_invoices` | (فاضي دلوقتي) |
| رسوم البطولات | `tournament_participants` | (فاضي دلوقتي) |
| تأمينات عقود الأكاديميات | `sa_academy_contracts` | ٥٠٬٠٠٠ على ٤ |
| إيجار الأكاديميات | `sa_academy_contracts` | ٣١٥٬٠٠٠ على ٢ |
**الإجمالي: ١٬٣١٦ مطالبة بـ٨٣٥٬١٦٧ جنيه** ما كانتش في الدفاتر.
> **ملاحظتين مهمتين:**
> - **التأمين مش إيراد.** فلوس النادي ماسكها ولازم يرجّعها آخر العقد، فبتتقيّد التزام
> في «تأمينات للغير». لو اتسجّلت إيراد كانت هتضخّم النتيجة بالكامل وتخفي الالتزام.
> - **إيجار الأكاديميات بيتقيّد بالشهر المنقضي بس.** العقد كله مرة واحدة كان هيسحب
> إيراد سنين لشهر واحد.
### فلوس خارجة — مستمعين على أحداث
| المصدر | الحدث | القيد |
|---|---|---|
| صرف سلفة موظف | `hr.loan.disbursed` | مدين سلف عاملين / دائن نقدية |
| نهاية الخدمة | `hr.end_of_service.paid` | مدين مكافأت ترك الخدمة / دائن نقدية |
| مستحقات المدربين | `coach.payment.approved` | مدين مكافآت / دائن مصروفات مستحقة |
| استلام بضاعة | `procurement.grn_completed` | مدين مخزون / دائن بضاعة لم ترد فاتورتها |
| إهلاك الأصول | `inventory.depreciation_run` | مدين إهلاك / دائن مجمع إهلاك — **لكل فئة أصول** |
| فروق الجرد | `inventory.audit_completed` | عجز = خسارة، زيادة = بالعكس |
| استبعاد الأصول | `inventory.asset_disposed` | التكلفة + المجمع + الربح/الخسارة |
| الإعفاء من غرامة | `fine.waived` | عكس قيد الاستحقاق |
> **السلفة أصل مش مصروف.** النادي مستنيها ترجع، وبتتخصم من المرتب على أقساط.
> لو اتسجّلت مصروف كانت هتقلّل الربح والأصول بالمبلغ كله.
> **المطابقة الثلاثية للمشتريات.** استلام البضاعة بيقيّد المخزون مقابل حساب وسيط،
> والفاتورة لما تتعتمد بتقفل الحساب الوسيط وتقيّد المورد. من غير الحساب الوسيط ده،
> الاتنين كانوا هيقيّدوا المخزون فيتحسب مرتين — عشان كده استلام البضاعة كان مش موصّل
> أصلًا. الفاتورة دلوقتي بتشوف لو فيه استلام اتقيّد وبتغيّر الطرف المدين لوحدها.
---
## اللي **ما اتوصّلش** — وليه
دي **مش** مشاكل ربط. دي حاجات فيها فلوس ضمنيًا بس مفيش مبلغ مسجّل ولا جهة محددة.
أي رقم هنحطه هيبقى تخمين — ورقم غلط في الدفاتر أصعب في اكتشافه من رقم ناقص، وكمان
بيبان إنه مظبوط.
| المصدر | السطور | المشكلة | المطلوب |
|---|---|---|---|
| `sa:pool_zone_booking` | ٧٣٠ | فيه سعر تذكرة وعدد حاضرين، بس مفيش سجل لمين دخل ولا هل دفع | سجل دخول لكل شخص أو تذكرة |
| `sa:player_card` | ٥ | الجدول مفيهوش عمود مبلغ أصلًا | رسم إصدار/تجديد على الكارنيه |
| `facility:pool_booking` | ٠ | الكود بيسجّل كل حجز بصفر ثابت | تسعيرة — دي مشكلة تسعير مش محاسبة |
| `facility:private_match` | ٠ | المقدم بيتكتب في عمود من غير إيصال | تحصيل المقدم كدفعة عادية |
| `academy:enrollment` | ١ | القيد مالوش رسوم | مفيش مطلوب — الإيراد بييجي من اشتراك النشاط |
| `academy:settlement` | ٠ | **ميزة متكررة في موديولين** | قرار: أي جدول هو الأصل |
### حكاية `academy:settlement`
محرك التسويات بيقرا `academy_contracts` (**فاضي**)، والعقود الحقيقية في
`sa_academy_contracts` (**١٣ عقد**). الجدولين شكلهم شبه بعض تقريبًا — يعني الميزة
اتبنت مرتين في موديولين مختلفين.
ده مش خطأ إملائي أصلّحه بسطر. لو غيّرت `SettlementService` يقرا الجدول التاني،
الموديل والتقارير والـ joins كلها لسه بتقرا الجدول الأول. القرار «أي جدول هو الأصل
وإيه اللي يتنقل» قرار منتج، ودمجهم من غير ما حد يقرر ممكن يضيّع بيانات.
**بس محاسبيًا مش ضايع حاجة**: ماسح الاستحقاقات بيقرا `sa_academy_contracts` — الجدول
اللي فيه البيانات — وبيقيّد التأمينات والإيجار منه.
---
## الشاشة
`/accounting/accruals`**الاستحقاقات**
- اللي اتقيّد حسب المصدر، واللي لسه مفتوح
- أقدم المطالبات المفتوحة (اللي محدش لحق يجريها)
- اللي الماسح رفض يقيّده وليه
- زرار **«شغّل الفحص دلوقتي»**
> الكرون في النظام ده **بيتشحن مقفول** (`cron_enabled = 0`). الوظيفة
> `AccrualReconcileJob` جاهزة وبتشتغل مرة في اليوم لما يتفعّل — ولحد ما يتفعّل،
> شغّل الفحص من الزرار.
---
## ملفات
| الملف | مسؤول عن |
|---|---|
| `Services/Revenue/AccrualRunner.php` | الماسح — بيلاقي المستحق ويقفل المدفوع |
| `Services/Revenue/AccrualService.php` | القيد نفسه: `single` / `batch` / `release` |
| `Services/SubledgerService.php` | جدول الاستحقاقات + مديونية الأعضاء |
| `Services/OperationalPostingService.php` | الفلوس الخارجة والأصول |
| `cron/jobs/AccrualReconcileJob.php` | الجولة الليلية |
| `database/seeds/Phase_109_001_seed_accrual_rules.php` | قواعد الاستحقاق |
| `database/seeds/Phase_109_002_seed_operational_rules.php` | قواعد الصرف والمخزون |
---
## حاجة محتاجة قرار من المالية
**اشتراك العضو السنوي بينزل حاليًا على حساب «٤١٠٢٠١ اكاديمية البادل».**
ده مش صح — بس هو الربط الموجود فعلًا وعليه أكتر من ألف قيد قديم. قاعدة الاستحقاق
اتعملت على **نفس الحساب** عن قصد، عشان الاستحقاق والتحصيل يتقفلوا على بعض. لو
اتغيّر، لازم يتغيّر في الاتنين مع بعض — ودي مراجعة مالية مش تعديل كود.
نفس الكلام على `sa:monthly_subscription` اللي نازل على «إيرادات متنوعه».
# مسار الفلوس — الحسابات الوسيطة ودورة النقدية
> **الغرض:** تفهم الفرق بين **تقسيم** المبلغ و**تنقّل** المبلغ، وتعرف إزاي السيستم
> بيضمن إن كل خطوة بتفضّي الحساب اللي الخطوة اللي قبلها حطّت فيه الفلوس — وإزاي
> تكتشف الفلوس اللي واقفة في النص من غير ما تفتح دفتر الأستاذ.
---
## الفرق في سطرين
**معالج توزيع المبالغ** بيجاوب على سؤال: المبلغ ده يتقسّم على أنهي حسابات في **قيد واحد**؟
(٧٠٠ عضوية + ٢٠٠ تنمية + ١٠٠ دمغة).
**مسار الفلوس** بيجاوب على سؤال تاني خالص: **نفس الفلوس** وهي بتتنقّل من حساب لحساب
على مدى **كذا قيد**، كل ما يحصل أكشن جديد.
الفلوس اللي بتتحصّل على المكتب مش بتروح البنك على طول:
```
الكاشير حصّل نقدي → الفلوس في حساب «خزنة العضويات»
آخر الوردية اتسوّت → الفلوس اتنقلت لحساب «الخزنة الرئيسية»
الإيداع البنكي اتأكّد → الفلوس بقت في البنك
```
تلات أكشنز، تلات قيود، وكل قيد **لازم يفضّي** الحساب اللي قبله. لو ما فضّاهوش،
يبقى فيه فلوس واقفة في حساب ومحدش واخد باله.
---
## ليه الحكاية دي مهمة (اللي كان بيحصل قبل كده)
الخزائن الفرعية كانت مربوطة في دليل الحسابات بصناديق **العملات الأجنبية**:
| الخزنة | كانت بتنزل في |
|---|---|
| خزنة الأنشطة الرياضية | `12060102` الصندوق بالدولار |
| خزنة العضويات | `12060103` الصندوق باليورو |
يعني كل جنيه اتحصّل على المكتب نزل في صندوق بالعملة الأجنبية. وقت الكتابة كان فيه
**٩٧٢٬٧٩١ جنيه** قاعدين هناك.
وأسوأ من كده: قيد التسوية كان بيقول «مدين الخزنة الرئيسية / دائن حساب الخزنة الفرعية»،
بس «حساب الخزنة الفرعية» كان **مؤشر واحد لكل خزائن النادي**، ومش مربوط أصلًا. فالتسوية
كانت هتنزل على حساب **التحصيل ما نزلش فيه أصلًا** — القيد يتوازن، الشاشة تقول تمام،
والفلوس تتعدّ مرتين في الخزنة الرئيسية والصندوق بالدولار يبقى بالسالب.
الحل مش تظبيط الأرقام — الحل إن **الطرف التاني للقيد ما يتكتبش بالإيد أصلًا**، ده
اللي السلسلة بتعمله.
---
## مصطلحات
| المصطلح | المعنى بالبلدي |
|---|---|
| **السلسلة** (Chain) | الطريق اللي الفلوس بتمشي فيه. عندنا ٤: النقدية، الشيكات، مديونية الأعضاء، مستحقات الموردين. |
| **الخطوة** (Step) | محطة على الطريق. بتقول: الفلوس بتقعد فين، وبتفضّي أنهي حساب قبلها. |
| **الحساب الوسيط** (Clearing account) | حساب الفلوس بتعدّي منه مش بتقعد فيه. المفروض يفضى أول بأول. |
| **بتحجز فين** (Parks) | الحساب اللي الخطوة دي بتسيب الفلوس فيه. |
| **بتفضّي إيه** (Relieves) | الحساب اللي الخطوة دي بتشيل منه الفلوس. |
| **نوع الحركة** (Hop type) | **نقل** = فلوس بتتنقل من بيت لبيت. **سداد** = الطرفين بينقصوا (دفع لمورد). **استحقاق** = الطرفين بيزيدوا (فاتورة مورد). |
| **مرحلة بداية** (Entry point) | من هنا الفلوس بتدخل السلسلة. |
| **مرحلة نهاية** (Terminal) | هنا الفلوس بتستقر، مفيش خطوة بعدها. |
| **مسار بديل** (Branch) | مش الطريق الطبيعي: شيك ارتد، دين اتسقط. |
---
## الفكرة اللي بتمنع الكسر
الخطوة **ما بتكتبش** الحساب اللي هتفضّيه. بتقول: «أنا بفضّي المرحلة اللي قبلي».
والسيستم بيروح للمرحلة دي وبيسأل نفس السؤال اللي سألته وقت التحصيل، بنفس بيانات
المستند.
يعني: **الطرف الدائن في قيد التسوية هو حرفيًا نفس التعبير اللي حدّد الطرف المدين وقت
التحصيل.** مش قاعدتين اتصادف إنهم متفقين — تعبير واحد اتحسب مرتين. عشان كده مستحيل
تكتب سلسلة ما تتقفلش.
### الحسابات اللي مش ممكن تتكتب مرة واحدة
«حساب الخزنة اللي حصّلت» مختلف كل مرة. عشان كده الخطوة بتقول **إزاي تلاقي** الحساب،
مش **أنهي حساب**:
| الطريقة | يعني إيه |
|---|---|
| `treasury_of_txn` | حساب الخزنة اللي في المستند |
| `treasury_source` | حساب الخزنة المحوِّلة (في التسوية) |
| `treasury_target` | حساب الخزنة المستقبِلة |
| `bank_of_txn` | حساب البنك اللي في إذن الإيداع |
| `fixed_account` | حساب ثابت محدد |
| `stream_pointer` | مؤشر من شاشة توزيع المبالغ (`ar:control@accrual`) |
> المؤشر بياخد `@مرحلة` لأن نفس المصدر بيدّي حساب مختلف حسب المرحلة: `ar:control`
> بيدّي حساب المدينين وقت الاستحقاق وحساب الإعدام وقت الإسقاط.
---
## السلاسل الأربعة
### ١. دورة النقدية — `treasury:cash_lifecycle`
| # | الخطوة | بتفضّي | بتحجز في | مين بيرحّلها |
|---|---|---|---|---|
| ١ | التحصيل في الخزنة | — | حساب الخزنة | محرك التوزيع |
| ٢ | تسوية الوردية | حساب الخزنة المحوِّلة | حساب الخزنة المستقبِلة | **السلسلة** |
| ٣ | الإيداع البنكي | حساب الخزنة اللي في الإذن | حساب البنك | **السلسلة** |
دي السلسلة الوحيدة اللي السلسلة نفسها بترحّلها. الباقي خدمات موجودة بترحّلها،
والسلسلة بتعرّفها عشان الشاشة تعرض الطريق كامل والفحص يتأكد إن الطرفين متفقين.
### ٢. دورة الشيكات المستلمة — `instrument:cheque_receivable`
الشيك مش فلوس ساعة ما تستلمه. بيعدّي: أوراق قبض ← تحت التحصيل ← البنك، أو يرتد
ويرجع دين على العضو.
### ٣. دورة مديونية الأعضاء — `receivable:member`
استحقاق ← تحصيل (أو إسقاط). التحصيل هنا **مش إيراد تاني** — الإيراد اتسجّل وقت
الاستحقاق، والتحصيل تبديل دين بفلوس.
### ٤. دورة مستحقات الموردين — `payable:vendor`
اعتماد الفاتورة (استحقاق) ← السداد. السداد **مش مصروف تاني** — هو إطفاء التزام:
الدين بينقص والفلوس بتنقص، عشان كده نوعه «سداد» مش «نقل».
---
## الشاشات
### مسار الفلوس — `/accounting/posting-chains`
كل سلسلة وحالتها، والفلوس الواقفة فيها، وأقدم مبلغ. وفوق: الخزائن اللي ملهاش حساب،
والفلوس اللي محتاجة تصحيح تبويب، والخطوات اللي وقعت.
### فين الفلوس دلوقتي — `/accounting/posting-chains/parked`
**دي الشاشة اللي بتكشف السلسلة المكسورة من غير ما تفتح دفتر الأستاذ.** كل حساب وسيط،
رصيده الواقف، وأعمار المبالغ.
العمر محسوب **الأقدم يخرج الأول (FIFO)**: بنمشي على حركات الحساب بالترتيب، وكل مبلغ
خارج بياكل من أقدم المبالغ الداخلة. اللي فاضل في الآخر هو اللي **فعلًا** لسه واقف —
بتاريخه ومستنده. مش مجرد رصيد إجمالي.
> **مثال:** خزنة دخلها ٥٠٠٠ (من ٤٠ يوم) + ٣٠٠٠ (من ٩ أيام) + ٢٠٠٠ (إمبارح)، واتسوّى
> منها ٦٠٠٠. الرصيد ٤٠٠٠ — بس مش أي ٤٠٠٠: التسوية أكلت الـ٥٠٠٠ كلها و١٠٠٠ من
> التانية، فاللي واقف ٢٠٠٠ عمرهم ٩ أيام و٢٠٠٠ عمرهم يوم.
### تصحيح التبويب — `/accounting/posting-chains/reclassification`
بيعرض الفلوس اللي نزلت في صناديق العملات الأجنبية، ويرحّل **قيد تصحيح** ينقلها
لحسابها الصح.
- **القيود القديمة ما بتتغيّرش.** تعديل قيد مرحّل مش تصحيح.
- **مش ممكن يتظبط مرتين** — القيد بيتوسم على مستوى السطر، والشاشة بتخصم اللي اتنقل.
- **لو اتعكس، الشغل بيرجع يظهر تاني** — فينفع تعيده.
- الفلوس الأجنبية الحقيقية ما بتتلمسش: التطابق بيمشي على السطور اللي وراها **دفعة**
مكتوب فيها **خزنة**.
---
## الفحص الذاتي
فحص السلسلة بيقع لو:
- **طرفا الحركة نفس الحساب** — القيد هيتوازن ومش هينقل حاجة. ده بالظبط شكل المشكلة
القديمة، وهو **خطأ** مش تحذير.
- **نوع الحركة مش مناسب للحسابات** — «نقل» بين أصل والتزام بيحطّ الطرفين على نفس
الجانب.
- **مرحلة مش بداية ومش بتفضّي حاجة** — الفلوس هتظهر من العدم.
- **حساب رئيسي أو موقوف** — الترحيل بيرفضه.
- **مؤشر مش مربوط**، أو **خزنة ملهاش حساب**، أو **مفيش حساب بنكي معرّف**.
ونفس الفحوصات بتشتغل **وقت الترحيل** كمان، فمستحيل حركة تعدي بسبب حاجة الشاشة كانت
تقدر تشوفها.
---
## سجل الحركات — `posting_chain_hops`
كل خطوة اتنفّذت، ونتيجتها. مهم لأن الخطوة اللي **فشلت** مش هتبان في أي مكان تاني:
الفلوس هتفضل واقفة ومحدش يعرف هل حد ما سوّاش، ولا القيد وقع.
الجدول ده كمان بيمنع الترحيل مرتين: لو نفس المستند اتبعت تاني (ريتراي، أو ضغطة
دبل)، الخطوة بتترد من غير ما تعمل قيد جديد.
---
## ملفات مهمة
| الملف | مسؤول عن |
|---|---|
| `Services/Chain/ChainRegistry.php` | قراءة السلاسل وفحص صحتها |
| `Services/Chain/ChainAccountResolver.php` | تحويل «إزاي تلاقي الحساب» لحساب فعلي |
| `Services/Chain/ChainPostingService.php` | ترحيل الخطوة الواحدة |
| `Services/Chain/ClearingReconciliationService.php` | الأرصدة الواقفة وأعمارها (FIFO) |
| `Services/TreasuryAccountService.php` | حساب كل خزنة + تصحيح التبويب |
| `database/seeds/Phase_108_001_seed_posting_chains.php` | تعريف السلاسل الأربعة |
---
## حاجة لسه ناقصة
- **الحسابات البنكية مش معرّفة** — جدول `bank_accounts` فاضي، فخطوة الإيداع البنكي
مش هتلاقي حساب تنزل فيه. الفحص بيقول كده صراحة. عرّف الحساب البنكي واربطه بحسابه
في الدليل وهتبقى السلسلة سليمة.
- **٢٨ مصدر لسه `needs_code`** — مش بيبعتوا أي حدث، فالسلاسل اللي بتعتمد عليهم مش
هتوصلها فلوس. باين في «مركز التوصيل».
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