Commit 34bbb7fa authored by Mahmoud Aglan's avatar Mahmoud Aglan

push all

parent e4076074
This diff is collapsed.
......@@ -6,6 +6,7 @@ namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\App;
use App\Modules\Accounting\Services\JournalService;
use App\Core\Response;
use App\Modules\Accounting\Models\HistoricalBalance;
......@@ -64,7 +65,6 @@ class OpeningEntryController extends Controller
}
$db = App::getInstance()->db();
$session = App::getInstance()->session();
$fy = $db->selectOne("SELECT * FROM fiscal_years WHERE id = ? AND status = 'open'", [$fiscalYearId]);
if (!$fy) {
......@@ -100,35 +100,26 @@ class OpeningEntryController extends Controller
->withError('القيد غير متوازن — المدين: ' . number_format($totalDebit, 2) . ' الدائن: ' . number_format($totalCredit, 2));
}
$now = date('Y-m-d H:i:s');
$entryNumber = 'OPN-' . substr($fy['start_date'], 0, 4) . '-001';
$db->insert('journal_entries', [
'entry_number' => $entryNumber,
// Through JournalService, not around it: the fiscal-year and closed-period
// checks, the balance validation and the account-balance maintenance all
// live there, and an opening entry is not exempt from any of them.
$result = JournalService::createEntry([
'entry_date' => $fy['start_date'],
'fiscal_year_id' => $fiscalYearId,
'reference_type' => 'opening',
'description' => 'قيد افتتاحي — ' . ($fy['name_ar'] ?? ''),
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'posted',
'posted_at' => $now,
'created_by' => (int)($session->get('employee_id') ?? 0) ?: null,
'created_at' => $now,
'updated_at' => $now,
]);
$journalEntryId = (int)$db->lastInsertId();
foreach ($lines as $line) {
$db->insert('journal_entry_lines', [
'journal_entry_id' => $journalEntryId,
'account_id' => $line['account_id'],
'debit' => $line['debit'],
'credit' => $line['credit'],
'description_ar' => $line['description'],
'created_at' => $now,
]);
'reference_number' => 'OPN-' . substr((string) $fy['start_date'], 0, 4),
'description_ar' => 'قيد افتتاحي — ' . ($fy['name_ar'] ?? ''),
'source_module' => 'accounting',
'is_auto_generated' => 0,
], array_map(static fn(array $l): array => [
'account_id' => $l['account_id'],
'debit' => number_format($l['debit'], 2, '.', ''),
'credit' => number_format($l['credit'], 2, '.', ''),
'description_ar' => $l['description'],
], $lines), true);
if (empty($result['success'])) {
return $this->redirect('/accounting/opening-entries/create')
->withError($result['error'] ?? 'فشل إنشاء القيد الافتتاحي');
}
return $this->redirect('/accounting/opening-entries?fiscal_year_id=' . $fiscalYearId)
......
......@@ -1455,6 +1455,10 @@ class RevenueMappingController extends Controller
'description_ar' => ($row['description_ar'] ?? '') !== '' ? (string) $row['description_ar'] : null,
'max_amount' => ($row['max_amount'] ?? '') !== '' ? (string) $row['max_amount'] : null,
'min_amount' => null,
'is_appropriation' => !empty($row['is_appropriation']) ? 1 : 0,
'appropriation_source_account_id' => !empty($row['appropriation_source_account_id'])
? (int) $row['appropriation_source_account_id'] : null,
'revenue_account_id' => !empty($row['revenue_account_id']) ? (int) $row['revenue_account_id'] : null,
'is_active' => 1,
];
}
......
......@@ -49,52 +49,29 @@ class DailyTransactionService
];
}
$fy = $db->selectOne("SELECT id FROM fiscal_years WHERE is_current = 1 AND is_archived = 0");
if (!$fy) {
return null;
}
$now = date('Y-m-d H:i:s');
$entryNumber = 'DT-' . str_replace('-', '', $date) . '-001';
$existing = $db->selectOne("SELECT id FROM journal_entries WHERE entry_number = ?", [$entryNumber]);
if ($existing) {
$suffix = $db->selectOne(
"SELECT COUNT(*) as cnt FROM journal_entries WHERE entry_number LIKE ?",
['DT-' . str_replace('-', '', $date) . '%']
);
$num = ((int)($suffix['cnt'] ?? 0)) + 1;
$entryNumber = 'DT-' . str_replace('-', '', $date) . '-' . str_pad((string)$num, 3, '0', STR_PAD_LEFT);
}
$db->insert('journal_entries', [
'entry_number' => $entryNumber,
// Through JournalService: a consolidated daily entry must respect the
// closed-period lock and maintain account balances like any other.
$result = JournalService::createEntry([
'entry_date' => $date,
'fiscal_year_id' => (int)$fy['id'],
'reference_type' => 'daily_consolidated',
'description' => 'قيد مجمع — حركات يومية ' . $date,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'status' => 'posted',
'posted_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$journalEntryId = (int)$db->lastInsertId();
foreach ($lines as $line) {
$db->insert('journal_entry_lines', [
'journal_entry_id' => $journalEntryId,
'account_id' => $line['account_id'],
'debit' => $line['debit'],
'credit' => $line['credit'],
'cost_center_id' => $line['cost_center_id'],
'description_ar' => $line['description_ar'],
'created_at' => $now,
]);
'reference_number' => 'DT-' . str_replace('-', '', $date),
'description_ar' => 'قيد مجمع — حركات يومية ' . $date,
'source_module' => 'accounting',
'is_auto_generated' => 1,
], array_map(static fn(array $l): array => [
'account_id' => $l['account_id'],
'debit' => number_format((float) $l['debit'], 2, '.', ''),
'credit' => number_format((float) $l['credit'], 2, '.', ''),
'cost_center_id' => $l['cost_center_id'],
'description_ar' => $l['description_ar'],
], $lines), true);
if (empty($result['success'])) {
return null;
}
$journalEntryId = (int) $result['journal_entry_id'];
$txIds = array_column($transactions, 'id');
if (!empty($txIds)) {
$placeholders = implode(',', array_fill(0, count($txIds), '?'));
......
......@@ -209,10 +209,15 @@ final class JournalService
return ['success' => false, 'error' => 'القيد ملغي — لا يمكن ترحيله'];
}
// Re-validate period is open
// Re-validate the period AND the year. A draft raised while both were
// open must not slip into a year that has since been closed.
$fiscalYear = FiscalYear::find((int) $entry->fiscal_year_id);
if (!$fiscalYear || !$fiscalYear->isOpen()) {
return ['success' => false, 'error' => 'السنة المالية مغلقة — لا يمكن ترحيل القيد'];
}
$period = substr($entry->entry_date, 0, 7);
if (PeriodClosing::isPeriodClosed((int) $entry->fiscal_year_id, $period)) {
return ['success' => false, 'error' => 'الفترة مغلقة — لا يمكن ترحيل القيد'];
return ['success' => false, 'error' => 'الفترة ' . $period . ' مغلقة — لا يمكن ترحيل القيد'];
}
$db->beginTransaction();
......
......@@ -340,11 +340,53 @@ final class AllocationPlanService
$warnings[] = 'تاريخ السريان قديم جدًا — تأكد إنه مقصود';
}
// ── Appropriation lines ─────────────────────────────────────
// An earmark is not a different kind of revenue. The main entry credits
// revenue in full; the share is moved out of equity separately. So an
// appropriation line needs BOTH ends of that second entry.
foreach ($lines as $i => $l) {
if (empty($l['is_appropriation'])) {
continue;
}
$n = $i + 1;
if ($l['allocation_method'] === 'remainder') {
$errors[] = "البند {$n}: بند «الباقي» ما ينفعش يكون تخصيص — التخصيص لازم نسبة أو مبلغ محدد";
}
if ((int) ($l['revenue_account_id'] ?? 0) <= 0) {
$errors[] = "البند {$n}: التخصيص محتاج حساب الإيراد اللي هيتسجّل فيه المبلغ كامل الأول";
}
if ((int) ($l['appropriation_source_account_id'] ?? 0) <= 0) {
$errors[] = "البند {$n}: التخصيص محتاج حساب مصدر التخصيص (أرباح مرحّلة أو حساب تخصيص)";
}
if (($l['line_type'] ?? '') === 'revenue') {
$errors[] = "البند {$n}: بند التخصيص وجهته صندوق أو احتياطي — مش حساب إيراد";
}
}
// ── Each target, against its own direction ──────────────────
if (!$targets) {
$errors[] = 'ما فيش أي مصدر إيراد مطابق للنطاق اللي اخترته';
}
$enforceAccrual = self::setting('accounting.enforce_accrual_before_collection', '1') === '1';
$deferCategories = array_filter(array_map(
'trim',
explode(',', self::setting('accounting.deferral_required_categories', 'subscription'))
));
// Line types that put revenue into the income statement right now.
$recognisesRevenue = static function (array $l) use ($accounts): bool {
if (!empty($l['is_appropriation'])) {
return true; // the main entry still credits revenue
}
if (($l['line_type'] ?? '') !== 'revenue') {
return false;
}
$a = $accounts[(int) ($l['account_id'] ?? 0)] ?? null;
return $a === null || $a['account_type'] === 'revenue';
};
$seenDirections = [];
foreach ($targets as $t) {
$s = $t['stream'];
......@@ -373,6 +415,40 @@ final class AllocationPlanService
. '" ومش مناسب لبند "' . $allowedTypes[$lt]['label'] . '"';
}
}
// ── Revenue is earned once, not twice ───────────────
// If the stream already recognises revenue when the club raises
// the claim, then collection is a cash-for-receivable swap. A
// revenue line here would book the same income a second time and
// leave the receivable outstanding for ever.
if ($enforceAccrual && $stage === 'collection' && self::recognisesAtAccrual((int) $s['id'])) {
foreach ($lines as $i => $l) {
if ($recognisesRevenue($l)) {
$errors[] = $s['name_ar'] . ': البند ' . ($i + 1)
. ' بيسجّل إيراد وقت التحصيل، والمصدر ده بيسجّل إيراده وقت الاستحقاق. '
. 'التحصيل هنا تسوية ذمم — استخدم نوع البند «تسوية ذمم مدينة».';
break;
}
}
}
// ── Cash received ≠ revenue earned ──────────────────
// A year's subscription collected today is a promise to serve for
// a year, not a year's income. EAS 48 / IFRS 15 require deferral.
if (\in_array($s['category'], $deferCategories, true)
&& \in_array($stage, ['collection', 'accrual'], true)) {
foreach ($lines as $i => $l) {
if (($l['line_type'] ?? '') === 'revenue'
&& ($l['recognition_method'] ?? 'immediate') === 'immediate'
&& empty($l['is_appropriation'])) {
$errors[] = $s['name_ar'] . ' ('
. (self::CATEGORY_LABELS[$s['category']] ?? $s['category']) . '): البند ' . ($i + 1)
. ' بيسجّل الإيراد كامل فورًا. الفئة دي الخدمة فيها بتتقدّم على مدى فترة، '
. 'فلازم نوع البند يكون «إيراد مؤجل» ويترحّل شهريًا.';
break;
}
}
}
}
}
......@@ -493,6 +569,9 @@ final class AllocationPlanService
'recognized_account_id' => self::nullableInt($l['recognized_account_id'] ?? null),
'description_ar' => self::nullableString($l['description_ar'] ?? null),
'max_amount' => self::nullableString($l['max_amount'] ?? null),
'is_appropriation' => !empty($l['is_appropriation']) ? 1 : 0,
'appropriation_source_account_id' => self::nullableInt($l['appropriation_source_account_id'] ?? null),
'revenue_account_id' => self::nullableInt($l['revenue_account_id'] ?? null),
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
......@@ -601,6 +680,54 @@ final class AllocationPlanService
// ────────────────────────────────────────────────────────────
/**
* Does this stream already put revenue in the income statement at accrual?
* If it does, collection must not do it again.
*/
public static function recognisesAtAccrual(int $streamId): bool
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT 1 AS ok
FROM revenue_posting_rules r
JOIN revenue_posting_rule_lines l ON l.rule_id = r.id AND l.is_active = 1
JOIN chart_of_accounts c ON c.id = l.account_id
WHERE r.stream_id = ?
AND r.stage = 'accrual'
AND r.status = 'active'
AND r.effective_from <= CURDATE()
AND (r.effective_to IS NULL OR r.effective_to >= CURDATE())
AND (l.line_type = 'revenue' OR l.is_appropriation = 1)
AND c.account_type = 'revenue'
LIMIT 1",
[$streamId]
);
return $row !== null;
}
/** A policy switch out of system_config, so finance can change it. */
public static function setting(string $key, string $default): string
{
static $cache = [];
if (\array_key_exists($key, $cache)) {
return $cache[$key];
}
try {
$row = App::getInstance()->db()->selectOne(
"SELECT config_value FROM system_config WHERE config_key = ?",
[$key]
);
$cache[$key] = $row !== null && $row['config_value'] !== null
? (string) $row['config_value']
: $default;
} catch (\Throwable) {
$cache[$key] = $default; // config table missing before its migration
}
return $cache[$key];
}
private static function isDate(string $d): bool
{
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) && strtotime($d) !== false;
......
......@@ -246,6 +246,13 @@ final class RevenueAllocator
'recognized_account_id' => isset($line['recognized_account_id']) && $line['recognized_account_id']
? (int) $line['recognized_account_id']
: null,
'is_appropriation' => !empty($line['is_appropriation']),
'appropriation_source_account_id' => isset($line['appropriation_source_account_id']) && $line['appropriation_source_account_id']
? (int) $line['appropriation_source_account_id']
: null,
'revenue_account_id' => isset($line['revenue_account_id']) && $line['revenue_account_id']
? (int) $line['revenue_account_id']
: null,
];
}
......
......@@ -79,7 +79,82 @@ final class RevenuePostingEngine
RevenueRecognitionService::schedule($plan['deferrals'], $ctx, $entryId, $plan['stream']['id'] ?? null);
}
return ['success' => true, 'journal_entry_id' => $entryId, 'used_rule' => true];
// Earmarks move AFTER the income is recognised, as a transfer out of
// equity — never as a reduction of the revenue itself. Its own entry, so
// the income statement and the appropriation read separately.
$appropriationEntryId = null;
if (!empty($plan['appropriations'])) {
$appropriationEntryId = self::postAppropriations($plan, $ctx, $entryId);
}
return [
'success' => true,
'journal_entry_id' => $entryId,
'appropriation_entry_id' => $appropriationEntryId,
'used_rule' => true,
];
}
/**
* Post the earmark transfers as one separate entry:
* Dr appropriation source (retained earnings / appropriation account)
* Cr the fund
*
* A failure here does not undo the revenue entry — the income was genuinely
* earned and recognised. It is logged so the transfer can be re-run.
*/
private static function postAppropriations(array $plan, array $ctx, int $originEntryId): ?int
{
$lines = [];
foreach ($plan['appropriations'] as $ap) {
if (bccomp($ap['amount'], '0.00', self::SCALE) <= 0) {
continue;
}
$lines[] = [
'account_id' => $ap['source_account_id'],
'debit' => $ap['amount'],
'credit' => '0.00',
'description_ar' => $ap['description_ar'],
'cost_center_id' => $ap['cost_center_id'],
'branch_id' => $ap['branch_id'],
];
$lines[] = [
'account_id' => $ap['fund_account_id'],
'debit' => '0.00',
'credit' => $ap['amount'],
'description_ar' => $ap['description_ar'],
'cost_center_id' => $ap['cost_center_id'],
'branch_id' => $ap['branch_id'],
];
}
if (count($lines) < 2) {
return null;
}
$result = JournalService::createEntry([
'entry_date' => $plan['header']['entry_date'] ?? date('Y-m-d'),
'description_ar' => 'تخصيص من الإيراد — ' . ($plan['header']['description_ar'] ?? ''),
'reference_type' => 'appropriation',
'reference_id' => $originEntryId,
'reference_number' => $ctx['reference_number'] ?? null,
'source_module' => $plan['header']['source_module'] ?? null,
'branch_id' => $plan['header']['branch_id'] ?? null,
'cost_center_id' => $plan['header']['cost_center_id'] ?? null,
'is_auto_generated' => 1,
'notes' => 'تخصيص أرباح مقابل القيد رقم ' . $originEntryId
. ' — الإيراد اتسجّل كامل، وده نقل للصندوق/الاحتياطي.',
], $lines, true);
if (empty($result['success'])) {
Logger::error('appropriation entry failed', [
'origin_entry_id' => $originEntryId,
'error' => $result['error'] ?? null,
]);
return null;
}
return (int) $result['journal_entry_id'];
}
/**
......@@ -95,7 +170,8 @@ final class RevenuePostingEngine
$blank = [
'resolved' => false, 'stream' => null, 'rule' => null, 'allocation' => null,
'header' => [], 'lines' => [], 'deferrals' => [], 'errors' => [], 'warnings' => [],
'header' => [], 'lines' => [], 'deferrals' => [], 'appropriations' => [],
'errors' => [], 'warnings' => [],
];
$amount = self::money((string) ($ctx['amount'] ?? '0'));
......@@ -204,15 +280,56 @@ final class RevenuePostingEngine
}
$deferrals = [];
$appropriations = [];
foreach ($alloc['allocations'] as $a) {
if (bccomp($a['amount'], '0.00', self::SCALE) <= 0) {
continue;
}
self::assertPostable($a['account_id'], 'حساب البند', $errors);
$lineDesc = $a['description_ar'] ?: $description;
// ── Earmark, not a different kind of income ─────────────
// "20% goes to the facilities fund" does not change what the club
// earned — it decides what to do with it afterwards. So the main
// entry credits REVENUE for this share, and the earmark leaves as a
// separate transfer out of equity. Crediting the fund here instead
// would understate revenue and invent a liability owed to nobody.
if (!empty($a['is_appropriation'])) {
$revenueAccountId = $a['revenue_account_id'];
$sourceAccountId = $a['appropriation_source_account_id'];
if (!$revenueAccountId || !$sourceAccountId) {
$errors[] = 'بند التخصيص بدون حساب إيراد أو حساب مصدر تخصيص';
continue;
}
self::assertPostable($revenueAccountId, 'حساب إيراد التخصيص', $errors);
self::assertPostable($sourceAccountId, 'حساب مصدر التخصيص', $errors);
self::assertPostable($a['account_id'], 'حساب الصندوق', $errors);
$jLines[] = [
'account_id' => $revenueAccountId,
'debit' => $isInflow ? '0.00' : $a['amount'],
'credit' => $isInflow ? $a['amount'] : '0.00',
'description_ar' => $lineDesc,
'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null,
'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null,
];
$appropriations[] = [
'rule_line_id' => $a['rule_line_id'],
'source_account_id' => (int) $sourceAccountId,
'fund_account_id' => (int) $a['account_id'],
'amount' => $a['amount'],
'cost_center_id' => $a['cost_center_id'] ?? $rule['cost_center_id'] ?? null,
'branch_id' => $a['branch_id'] ?? $ctx['branch_id'] ?? null,
'description_ar' => 'تخصيص — ' . $lineDesc,
];
continue;
}
self::assertPostable($a['account_id'], 'حساب البند', $errors);
// Contra-revenue always reduces revenue, so it is always a debit —
// on a collection with a discount and on a refund alike. Every other
// line type follows the entry's direction.
......@@ -297,6 +414,7 @@ final class RevenuePostingEngine
],
'lines' => $jLines,
'deferrals' => $deferrals,
'appropriations' => $appropriations,
'errors' => $errors,
'warnings' => $warnings,
'totals' => ['debit' => $dr, 'credit' => $cr],
......
......@@ -411,6 +411,41 @@
</div>
</div>
</div>
<?php if ($direction !== 'outflow'): ?>
<div class="wz-approp-box" style="margin-top:10px;">
<label style="display:flex;gap:7px;align-items:flex-start;font-size:12px;cursor:pointer;">
<input type="checkbox" class="wz-approp" style="margin-top:2px;">
<span>
<strong>الجزء ده تخصيص، مش نوع إيراد تاني</strong>
<span style="display:block;color:#6B7280;font-size:11px;margin-top:2px;">
يعني الفلوس دي النادي كسبها فعلًا، وإحنا بس بنحجزها لصندوق. الإيراد هيتسجّل
كامل، والحجز هيتعمل بقيد تخصيص منفصل من الأرباح.
</span>
</span>
</label>
<div class="wz-approp-fields" style="display:none;margin-top:9px;padding:10px;background:#EEF2FF;border:1px solid #C7D2FE;border-radius:6px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div style="position:relative;">
<label class="form-label" style="font-size:11px;">١ — الإيراد يتسجّل في</label>
<input type="text" class="form-input acct-search wz-ap-rev-search" placeholder="حساب الإيراد" autocomplete="off">
<input type="hidden" class="wz-ap-rev">
<div class="acct-results"></div>
</div>
<div style="position:relative;">
<label class="form-label" style="font-size:11px;">٢ — التخصيص يتخصم من</label>
<input type="text" class="form-input acct-search wz-ap-src-search" placeholder="أرباح مرحّلة / حساب تخصيص" autocomplete="off">
<input type="hidden" class="wz-ap-src">
<div class="acct-results"></div>
</div>
</div>
<div class="form-help" style="color:#3730A3;">
و«يروح لحساب» فوق هو الصندوق/الاحتياطي اللي هياخد الفلوس.
</div>
</div>
</div>
<?php endif; ?>
<div class="wz-after" style="margin-top:8px;font-size:11.5px;color:#6B7280;"></div>
</div>
</template>
......@@ -464,6 +499,9 @@
'type' => $l['line_type'],
'desc' => $l['description_ar'],
'months' => $l['recognition_months'],
'approp' => !empty($l['is_appropriation']),
'apRev' => (int) ($l['revenue_account_id'] ?? 0),
'apSrc' => (int) ($l['appropriation_source_account_id'] ?? 0),
'recAcct' => (int) ($l['recognized_account_id'] ?? 0),
'recLbl' => $l['recognized_code'] ? ($l['recognized_code'] . ' — ' . $l['recognized_name']) : '',
'defer' => $l['recognition_method'] === 'straight_line',
......@@ -582,9 +620,23 @@
var type = el.querySelector('.wz-type');
var defer = el.querySelector('.wz-defer');
wireSearch(el.querySelector('.wz-search'), el.querySelector('.wz-acct'), el.querySelector('.acct-results'));
var results = el.querySelectorAll('.acct-results');
wireSearch(el.querySelector('.wz-search'), el.querySelector('.wz-acct'), results[0]);
wireSearch(el.querySelector('.wz-rec-search'), el.querySelector('.wz-rec-acct'),
el.querySelectorAll('.acct-results')[1], function () { return 'revenue'; });
results[1], function () { return 'revenue'; });
var approp = el.querySelector('.wz-approp');
var apFields = el.querySelector('.wz-approp-fields');
var apRev = el.querySelector('.wz-ap-rev');
var apSrc = el.querySelector('.wz-ap-src');
if (approp) {
wireSearch(el.querySelector('.wz-ap-rev-search'), apRev, results[2], function () { return 'revenue'; });
wireSearch(el.querySelector('.wz-ap-src-search'), apSrc, results[3]);
approp.addEventListener('change', function () {
apFields.style.display = approp.checked ? 'block' : 'none';
recalc();
});
}
function syncDefer() {
defer.style.display = DEFERRABLE.indexOf(type.value) !== -1 ? 'block' : 'none';
......@@ -623,6 +675,12 @@
el.querySelector('.wz-rec-acct').value = data.recAcct || '';
el.querySelector('.wz-rec-search').value = data.recLbl || '';
}
if (data.approp && approp) {
approp.checked = true;
apFields.style.display = 'block';
apRev.value = data.apRev || '';
apSrc.value = data.apSrc || '';
}
}
syncDefer();
recalc();
......@@ -657,7 +715,12 @@
account: el.querySelector('.wz-acct').value,
desc: el.querySelector('.wz-desc').value,
months: el.querySelector('.wz-months').value,
recAcct: el.querySelector('.wz-rec-acct').value
recAcct: el.querySelector('.wz-rec-acct').value,
approp: !!(el.querySelector('.wz-approp') && el.querySelector('.wz-approp').checked),
apRev: el.querySelector('.wz-ap-rev') ? el.querySelector('.wz-ap-rev').value : '',
apSrc: el.querySelector('.wz-ap-src') ? el.querySelector('.wz-ap-src').value : '',
apRevLabel: el.querySelector('.wz-ap-rev-search') ? el.querySelector('.wz-ap-rev-search').value : '',
apSrcLabel: el.querySelector('.wz-ap-src-search') ? el.querySelector('.wz-ap-src-search').value : ''
};
});
......@@ -808,7 +871,10 @@
recognition_method: deferred ? 'straight_line' : 'immediate',
recognition_months: deferred ? s.months : '',
recognized_account_id: deferred ? s.recAcct : '',
max_amount: ''
max_amount: '',
is_appropriation: s.approp ? 1 : 0,
revenue_account_id: s.approp ? s.apRev : '',
appropriation_source_account_id: s.approp ? s.apSrc : ''
});
});
var remAcct = document.getElementById('wz-rem-acct').value;
......@@ -844,6 +910,18 @@
&& !el.querySelector('.wz-rec-acct').value) {
msgs.push('الجزء ' + (i + 1) + ': إيراد مؤجل من غير حساب يترحّل له.');
}
var ap = el.querySelector('.wz-approp');
if (ap && ap.checked) {
if (!el.querySelector('.wz-ap-rev').value) {
msgs.push('الجزء ' + (i + 1) + ': تخصيص من غير حساب الإيراد اللي هيتسجّل فيه.');
}
if (!el.querySelector('.wz-ap-src').value) {
msgs.push('الجزء ' + (i + 1) + ': تخصيص من غير حساب مصدر التخصيص.');
}
if (el.querySelector('.wz-method').value === 'remainder') {
msgs.push('الجزء ' + (i + 1) + ': بند الباقي ما ينفعش يكون تخصيص.');
}
}
});
if (pct > 100) msgs.push('مجموع النسب ' + pct + '٪ — أكبر من ١٠٠٪.');
if (Number(document.getElementById('tgt-count').textContent || 0) < 1) {
......
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Two accounting controls that could not be expressed before.
*
* 1. APPROPRIATION. "20% of the membership fee goes to the facilities fund" is
* almost never a revenue classification — it is an earmark. Crediting a fund
* liability straight out of the collection understates revenue and invents a
* liability owed to nobody. A line marked as an appropriation now credits the
* REVENUE account in the main entry, and the earmark is posted as a separate
* transfer out of equity: Dr appropriation source / Cr the fund.
*
* 2. DEFERRAL POLICY. Which stream categories may not recognise revenue on the
* day cash arrives. Held in system_config so finance can change it without a
* developer.
*
* Idempotent — safe to re-run.
*/
return static function (Database $db): void {
$has = static function (string $table, string $column) use ($db): bool {
return (bool) $db->selectOne(
"SELECT 1 AS ok FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?",
[$table, $column]
);
};
if (!$has('revenue_posting_rule_lines', 'is_appropriation')) {
$db->raw("
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `is_appropriation` TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = earmark: revenue is recognised in full, the share is transferred out of equity separately'
AFTER `line_type`
");
}
if (!$has('revenue_posting_rule_lines', 'appropriation_source_account_id')) {
$db->raw("
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `appropriation_source_account_id` BIGINT UNSIGNED NULL
COMMENT 'Debited by the transfer — retained earnings or an appropriation account'
AFTER `is_appropriation`
");
}
if (!$has('revenue_posting_rule_lines', 'revenue_account_id')) {
$db->raw("
ALTER TABLE `revenue_posting_rule_lines`
ADD COLUMN `revenue_account_id` BIGINT UNSIGNED NULL
COMMENT 'Appropriation lines only: the revenue account credited in the main entry'
AFTER `appropriation_source_account_id`
");
}
// ── Deferral policy, editable from settings ─────────────────────────
$settings = [
[
'config_key' => 'accounting.deferral_required_categories',
'config_value' => 'subscription',
'config_type' => 'string',
'group_name' => 'accounting',
'description_ar' => 'فئات الإيراد اللي ممنوع تسجّل إيرادها فورًا وقت التحصيل — لازم إيراد مؤجل. افصل بينهم بفاصلة.',
'description_en' => 'Revenue categories that may not recognise revenue immediately on collection. Comma separated.',
],
[
'config_key' => 'accounting.enforce_accrual_before_collection',
'config_value' => '1',
'config_type' => 'boolean',
'group_name' => 'accounting',
'description_ar' => 'امنع تسجيل الإيراد وقت التحصيل لو المصدر له قاعدة استحقاق — التحصيل ساعتها تسوية ذمم.',
'description_en' => 'Block revenue lines at collection when the stream already recognises at accrual.',
],
];
foreach ($settings as $s) {
$exists = $db->selectOne("SELECT id FROM system_config WHERE config_key = ?", [$s['config_key']]);
if ($exists) {
continue;
}
$db->insert('system_config', $s + [
'is_editable' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
};
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Three "مقدم عضوية" accounts were opened under 4105 إيرادات النادي, so money
* received before the service is delivered has been sitting in REVENUE:
*
* 410503 مقدم عضويه 2,774,495.00
* 410505 باقى مقدم عضويه 180,263.00
* 410504 جزء من مقدم 77,500.00
* ─────────────
* 3,032,258.00
*
* An advance is a contract liability (EAS 48 / IFRS 15) — the club owes a
* service, not a sale it has made. Leaving it in revenue overstates income,
* overstates the VAT base, and is the first thing an auditor pulls. The
* correctly-typed account already exists next door: 23081115 ايرادات مدفوعة
* مقدما, under 230811 حسابات دائنة أخري.
*
* This migration mirrors the three accounts into the liability section keeping
* their separate meanings, posts ONE dated correcting entry moving the balances,
* and blocks the old accounts from further posting. It does not delete them —
* the history stays readable.
*
* Idempotent: keyed on the entry's reference number, so a re-run posts nothing.
* Reversible: the entry can be reversed from the journal screen like any other.
*/
return static function (Database $db): void {
$REF = 'RECLASS-ADVANCES-001';
if ($db->selectOne("SELECT id FROM journal_entries WHERE reference_number = ?", [$REF])) {
return; // already corrected
}
// The liability parent that already holds ايرادات مدفوعة مقدما.
$parent = $db->selectOne(
"SELECT id, account_code, level FROM chart_of_accounts
WHERE account_code = '230811' AND is_archived = 0"
);
if (!$parent) {
return; // chart differs — leave it alone
}
$parentId = (int) $parent['id'];
$parentLevel = (int) ($parent['level'] ?? 4);
$map = [
'410503' => ['code' => '23081119', 'name' => 'مقدم عضوية — مقبوض مقدمًا'],
'410504' => ['code' => '23081120', 'name' => 'جزء من مقدم عضوية — مقبوض مقدمًا'],
'410505' => ['code' => '23081121', 'name' => 'باقي مقدم عضوية — مقبوض مقدمًا'],
];
$now = date('Y-m-d H:i:s');
$lines = [];
$total = '0.00';
foreach ($map as $oldCode => $new) {
$old = $db->selectOne(
"SELECT id, account_code, name_ar, current_balance, account_type
FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$oldCode]
);
if (!$old || $old['account_type'] !== 'revenue') {
continue; // already fixed, or not there
}
// Mirror account in the liability section.
$target = $db->selectOne("SELECT id FROM chart_of_accounts WHERE account_code = ?", [$new['code']]);
if ($target) {
$targetId = (int) $target['id'];
} else {
$targetId = (int) $db->insert('chart_of_accounts', [
'account_code' => $new['code'],
'name_ar' => $new['name'],
'name_en' => 'Membership advance — contract liability',
'account_type' => 'liability',
'account_nature' => 'credit',
'parent_id' => $parentId,
'level' => $parentLevel + 1,
'is_header' => 0,
'is_active' => 1,
'is_archived' => 0,
'current_balance' => '0.00',
'notes' => 'مُنشأ بقيد تصحيح ' . $REF . ' — نقل مقدمات الأعضاء من الإيرادات للالتزامات',
'created_at' => $now,
'updated_at' => $now,
]);
}
// A revenue account carries a credit balance; debit it back to zero.
$balance = (string) $old['current_balance'];
if (bccomp($balance, '0.00', 2) === 0) {
continue;
}
$lines[] = [
'account_id' => (int) $old['id'],
'debit' => $balance,
'credit' => '0.00',
'description_ar' => 'إقفال ' . $old['name_ar'] . ' — إعادة تبويب من الإيرادات',
];
$lines[] = [
'account_id' => $targetId,
'debit' => '0.00',
'credit' => $balance,
'description_ar' => 'إثبات ' . $new['name'] . ' — التزام عقدي',
];
$total = bcadd($total, $balance, 2);
}
if (bccomp($total, '0.00', 2) <= 0) {
return; // nothing to move
}
// Post into the open fiscal year, dated today, through the same path as any
// other entry so the period lock and balance maintenance both apply.
\App\Core\App::getInstance()->setDb($db);
$result = \App\Modules\Accounting\Services\JournalService::createEntry([
'entry_date' => date('Y-m-d'),
'reference_type' => 'reclassification',
'reference_number' => $REF,
'description_ar' => 'قيد تصحيح — إعادة تبويب مقدمات العضوية من الإيرادات إلى الالتزامات (' . $total . ' جنيه)',
'description_en' => 'Reclassification of membership advances from revenue to contract liability',
'source_module' => 'accounting',
'is_auto_generated' => 1,
'notes' => 'المقدم التزام عقدي وليس إيرادًا — معيار المحاسبة المصري ٤٨. '
. 'الحسابات القديمة اتقفلت ومنعت من الترحيل، والأرصدة اتنقلت لحسابات الالتزامات المقابلة.',
], $lines, true);
if (empty($result['success'])) {
throw new \RuntimeException('فشل قيد إعادة التبويب: ' . ($result['error'] ?? 'سبب غير معروف'));
}
// Stop anything posting to the revenue-side accounts again. They stay
// visible with a zero balance so the history reads straight.
foreach (array_keys($map) as $oldCode) {
$db->query(
"UPDATE chart_of_accounts
SET is_active = 0,
notes = CONCAT(COALESCE(notes, ''), ' | موقوف بقيد ', ?, ' — الرصيد اتنقل لحساب الالتزامات المقابل'),
updated_at = ?
WHERE account_code = ? AND account_type = 'revenue'",
[$REF, $now, $oldCode]
);
}
};
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