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'],
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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,
];
}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -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, '.', '');
}
}
This diff is collapsed.
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مسار الفلوس<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<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(); ?>
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تصحيح تبويب نقدية الخزائن<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:16px;">
<a href="/accounting/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(); ?>
This diff is collapsed.
......@@ -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();
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment