Commit 27dbd900 authored by Mahmoud Aglan's avatar Mahmoud Aglan

test

parent 3fcec655
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\BankAccount;
use App\Modules\Accounting\Models\Account;
class BankAccountController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.bank_account.view');
$accounts = BankAccount::query()
->where('is_archived', '=', 0)
->orderBy('account_name_ar', 'ASC')
->get();
return $this->view('Accounting/Views/bank_accounts/index', [
'accounts' => $accounts,
]);
}
public function create(): Response
{
$this->authorize('accounting.bank_account.manage');
$glAccounts = Account::query()
->where('is_archived', '=', 0)
->where('is_bank_account', '=', 1)
->where('is_header', '=', 0)
->orderBy('account_code', 'ASC')
->get();
if (empty($glAccounts)) {
$glAccounts = Account::query()
->where('is_archived', '=', 0)
->where('account_code', 'LIKE', '1102%')
->where('is_header', '=', 0)
->orderBy('account_code', 'ASC')
->get();
}
return $this->view('Accounting/Views/bank_accounts/create', [
'gl_accounts' => $glAccounts,
]);
}
public function store(): Response
{
$this->authorize('accounting.bank_account.manage');
$data = $this->validate($_POST, [
'account_name_ar' => 'required',
'bank_name_ar' => 'required',
'account_number' => 'required',
]);
$isDefault = (int) ($_POST['is_default'] ?? 0);
if ($isDefault) {
$db = App::getInstance()->db();
$db->execute("UPDATE bank_accounts SET is_default = 0 WHERE is_default = 1");
}
$ba = BankAccount::create([
'account_name_ar' => $data['account_name_ar'],
'account_name_en' => $_POST['account_name_en'] ?? '',
'bank_name_ar' => $data['bank_name_ar'],
'bank_name_en' => $_POST['bank_name_en'] ?? null,
'account_number' => $data['account_number'],
'iban' => $_POST['iban'] ?? null,
'swift_code' => $_POST['swift_code'] ?? null,
'branch_name' => $_POST['branch_name'] ?? null,
'currency' => $_POST['currency'] ?? 'EGP',
'gl_account_id' => !empty($_POST['gl_account_id']) ? (int) $_POST['gl_account_id'] : null,
'opening_balance' => $_POST['opening_balance'] ?? '0.00',
'current_balance' => $_POST['opening_balance'] ?? '0.00',
'is_default' => $isDefault,
'is_active' => 1,
'notes' => $_POST['notes'] ?? null,
]);
// Link GL account
if (!empty($_POST['gl_account_id'])) {
$db = App::getInstance()->db();
$db->update('chart_of_accounts', [
'is_bank_account' => 1,
'bank_account_id' => $ba->id,
], '`id` = ?', [(int) $_POST['gl_account_id']]);
}
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء الحساب البنكي بنجاح']]);
return $this->redirect('/accounting/bank-accounts');
}
public function edit(int $id): Response
{
$this->authorize('accounting.bank_account.manage');
$account = BankAccount::findOrFail($id);
$glAccounts = Account::query()
->where('is_archived', '=', 0)
->where('account_type', '=', 'asset')
->where('is_header', '=', 0)
->orderBy('account_code', 'ASC')
->get();
return $this->view('Accounting/Views/bank_accounts/edit', [
'account' => $account->toArray(),
'gl_accounts' => $glAccounts,
]);
}
public function update(int $id): Response
{
$this->authorize('accounting.bank_account.manage');
$account = BankAccount::findOrFail($id);
$data = $this->validate($_POST, [
'account_name_ar' => 'required',
'bank_name_ar' => 'required',
]);
$account->update([
'account_name_ar' => $data['account_name_ar'],
'account_name_en' => $_POST['account_name_en'] ?? '',
'bank_name_ar' => $data['bank_name_ar'],
'bank_name_en' => $_POST['bank_name_en'] ?? null,
'iban' => $_POST['iban'] ?? null,
'swift_code' => $_POST['swift_code'] ?? null,
'branch_name' => $_POST['branch_name'] ?? null,
'is_active' => (int) ($_POST['is_active'] ?? 1),
'notes' => $_POST['notes'] ?? null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم تحديث الحساب البنكي بنجاح']]);
return $this->redirect('/accounting/bank-accounts');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\BankReconciliation;
use App\Modules\Accounting\Models\BankAccount;
use App\Modules\Accounting\Services\BankReconciliationService;
class BankReconciliationController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.bank_recon.view');
$reconciliations = BankReconciliation::query()
->where('is_archived', '=', 0)
->orderBy('reconciliation_date', 'DESC')
->limit(50)
->get();
$db = App::getInstance()->db();
foreach ($reconciliations as &$r) {
$ba = $db->selectOne("SELECT account_name_ar FROM bank_accounts WHERE id = ?", [(int) $r['bank_account_id']]);
$r['bank_account_name'] = $ba['account_name_ar'] ?? '—';
}
unset($r);
return $this->view('Accounting/Views/bank_reconciliation/index', [
'reconciliations' => $reconciliations,
]);
}
public function create(): Response
{
$this->authorize('accounting.bank_recon.manage');
$bankAccounts = BankAccount::getActive();
return $this->view('Accounting/Views/bank_reconciliation/create', [
'bank_accounts' => $bankAccounts,
]);
}
public function store(): Response
{
$this->authorize('accounting.bank_recon.manage');
$this->validate($_POST, [
'bank_account_id' => 'required|numeric',
'statement_date' => 'required',
'statement_balance' => 'required|numeric',
]);
$result = BankReconciliationService::create($_POST);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء المطابقة البنكية']]);
return $this->redirect('/accounting/bank-reconciliation/' . $result['reconciliation_id']);
}
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
return $this->redirect('/accounting/bank-reconciliation/create');
}
public function show(int $id): Response
{
$this->authorize('accounting.bank_recon.view');
$recon = BankReconciliation::findOrFail($id);
$items = $recon->items();
$bankAccount = BankAccount::find((int) $recon->bank_account_id);
$transactions = BankReconciliationService::getUnreconciledTransactions(
(int) $recon->bank_account_id,
$recon->statement_date
);
return $this->view('Accounting/Views/bank_reconciliation/show', [
'recon' => $recon->toArray(),
'items' => $items,
'bank_account' => $bankAccount ? $bankAccount->toArray() : [],
'transactions' => $transactions,
]);
}
public function addItem(int $id): Response
{
$this->authorize('accounting.bank_recon.manage');
$this->validate($_POST, [
'item_type' => 'required',
'description_ar' => 'required',
'amount' => 'required|numeric',
]);
$result = BankReconciliationService::addItem($id, $_POST);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إضافة بند المطابقة']]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/bank-reconciliation/' . $id);
}
public function complete(int $id): Response
{
$this->authorize('accounting.bank_recon.manage');
$result = BankReconciliationService::complete($id);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إكمال المطابقة البنكية بنجاح']]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/bank-reconciliation/' . $id);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\Account;
use App\Modules\Accounting\Models\CostCenter;
class ChartOfAccountsController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.coa.view');
$tree = Account::getTree();
return $this->view('Accounting/Views/chart_of_accounts/index', [
'tree' => $tree,
]);
}
public function create(): Response
{
$this->authorize('accounting.coa.manage');
$parentAccounts = Account::query()
->where('is_archived', '=', 0)
->where('is_header', '=', 1)
->orderBy('account_code', 'ASC')
->get();
$costCenters = CostCenter::getActive();
return $this->view('Accounting/Views/chart_of_accounts/create', [
'parents' => $parentAccounts,
'cost_centers' => $costCenters,
]);
}
public function store(): Response
{
$this->authorize('accounting.coa.manage');
$data = $this->validate($_POST, [
'account_code' => 'required',
'name_ar' => 'required',
'name_en' => 'required',
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
'account_nature' => 'required|in:debit,credit',
]);
// Check unique code
if (Account::findByCode($data['account_code'])) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'كود الحساب مستخدم بالفعل']]);
return $this->redirect('/accounting/chart-of-accounts/create');
}
$parentId = !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null;
$level = 1;
if ($parentId) {
$parent = Account::find($parentId);
if ($parent) {
$level = (int) $parent->level + 1;
}
}
Account::create([
'account_code' => $data['account_code'],
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'account_type' => $data['account_type'],
'account_nature' => $data['account_nature'],
'parent_id' => $parentId,
'level' => $level,
'is_header' => (int) ($_POST['is_header'] ?? 0),
'is_active' => 1,
'description_ar' => $_POST['description_ar'] ?? null,
'description_en' => $_POST['description_en'] ?? null,
'opening_balance' => $_POST['opening_balance'] ?? '0.00',
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء الحساب بنجاح']]);
return $this->redirect('/accounting/chart-of-accounts');
}
public function edit(int $id): Response
{
$this->authorize('accounting.coa.manage');
$account = Account::findOrFail($id);
$parentAccounts = Account::query()
->where('is_archived', '=', 0)
->where('is_header', '=', 1)
->where('id', '!=', $id)
->orderBy('account_code', 'ASC')
->get();
$costCenters = CostCenter::getActive();
return $this->view('Accounting/Views/chart_of_accounts/edit', [
'account' => $account->toArray(),
'parents' => $parentAccounts,
'cost_centers' => $costCenters,
]);
}
public function update(int $id): Response
{
$this->authorize('accounting.coa.manage');
$account = Account::findOrFail($id);
$data = $this->validate($_POST, [
'name_ar' => 'required',
'name_en' => 'required',
'account_type' => 'required|in:asset,liability,equity,revenue,expense',
'account_nature' => 'required|in:debit,credit',
]);
// Cannot change code of system accounts
if ((int) $account->is_system === 1 && ($_POST['account_code'] ?? '') !== $account->account_code) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'لا يمكن تعديل كود حساب نظامي']]);
return $this->redirect('/accounting/chart-of-accounts/' . $id . '/edit');
}
$account->update([
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'account_type' => $data['account_type'],
'account_nature' => $data['account_nature'],
'is_header' => (int) ($_POST['is_header'] ?? 0),
'is_active' => (int) ($_POST['is_active'] ?? 1),
'description_ar' => $_POST['description_ar'] ?? null,
'description_en' => $_POST['description_en'] ?? null,
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم تحديث الحساب بنجاح']]);
return $this->redirect('/accounting/chart-of-accounts');
}
public function search(): Response
{
$this->authorize('accounting.coa.view');
$term = $_GET['q'] ?? '';
$results = Account::search($term);
return $this->json($results);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\CostCenter;
class CostCenterController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.cost_center.view');
$centers = CostCenter::query()
->where('is_archived', '=', 0)
->orderBy('code', 'ASC')
->get();
// Fetch branch names
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/cost_centers/index', [
'centers' => $centers,
'branches' => $branches,
]);
}
public function create(): Response
{
$this->authorize('accounting.cost_center.manage');
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar, name_en FROM branches WHERE is_archived = 0 ORDER BY name_ar");
$parents = CostCenter::query()
->where('is_archived', '=', 0)
->orderBy('code', 'ASC')
->get();
return $this->view('Accounting/Views/cost_centers/create', [
'branches' => $branches,
'parents' => $parents,
]);
}
public function store(): Response
{
$this->authorize('accounting.cost_center.manage');
$data = $this->validate($_POST, [
'code' => 'required',
'name_ar' => 'required',
'name_en' => 'required',
'type' => 'required|in:cost_center,profit_center',
]);
if (CostCenter::findByCode($data['code'])) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'كود مركز التكلفة مستخدم بالفعل']]);
return $this->redirect('/accounting/cost-centers/create');
}
CostCenter::create([
'code' => $data['code'],
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'type' => $data['type'],
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'parent_id' => !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null,
'is_active' => 1,
'description' => $_POST['description'] ?? null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء مركز التكلفة بنجاح']]);
return $this->redirect('/accounting/cost-centers');
}
public function edit(int $id): Response
{
$this->authorize('accounting.cost_center.manage');
$center = CostCenter::findOrFail($id);
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar, name_en FROM branches WHERE is_archived = 0 ORDER BY name_ar");
$parents = CostCenter::query()
->where('is_archived', '=', 0)
->where('id', '!=', $id)
->orderBy('code', 'ASC')
->get();
return $this->view('Accounting/Views/cost_centers/edit', [
'center' => $center->toArray(),
'branches' => $branches,
'parents' => $parents,
]);
}
public function update(int $id): Response
{
$this->authorize('accounting.cost_center.manage');
$center = CostCenter::findOrFail($id);
$data = $this->validate($_POST, [
'name_ar' => 'required',
'name_en' => 'required',
'type' => 'required|in:cost_center,profit_center',
]);
$center->update([
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'type' => $data['type'],
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'parent_id' => !empty($_POST['parent_id']) ? (int) $_POST['parent_id'] : null,
'is_active' => (int) ($_POST['is_active'] ?? 1),
'description' => $_POST['description'] ?? null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم تحديث مركز التكلفة بنجاح']]);
return $this->redirect('/accounting/cost-centers');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Services\PeriodClosingService;
class FiscalYearController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.fiscal_year.view');
$years = FiscalYear::query()
->where('is_archived', '=', 0)
->orderBy('start_date', 'DESC')
->get();
return $this->view('Accounting/Views/fiscal_years/index', [
'years' => $years,
]);
}
public function create(): Response
{
$this->authorize('accounting.fiscal_year.manage');
$config = App::getInstance()->config('app');
$startMonth = (int) ($config['financial_year_start_month'] ?? 7);
$currentYear = (int) date('Y');
return $this->view('Accounting/Views/fiscal_years/create', [
'start_month' => $startMonth,
'current_year' => $currentYear,
]);
}
public function store(): Response
{
$this->authorize('accounting.fiscal_year.manage');
$data = $this->validate($_POST, [
'name_ar' => 'required',
'name_en' => 'required',
'start_date' => 'required',
'end_date' => 'required',
]);
// Validate dates
if (strtotime($data['end_date']) <= strtotime($data['start_date'])) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'تاريخ النهاية يجب أن يكون بعد تاريخ البداية']]);
return $this->redirect('/accounting/fiscal-years/create');
}
// Check overlap
$db = App::getInstance()->db();
$overlap = $db->selectOne(
"SELECT id FROM fiscal_years
WHERE is_archived = 0
AND ((start_date <= ? AND end_date >= ?) OR (start_date <= ? AND end_date >= ?))",
[$data['end_date'], $data['start_date'], $data['start_date'], $data['end_date']]
);
if ($overlap) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'يوجد تداخل مع سنة مالية أخرى']]);
return $this->redirect('/accounting/fiscal-years/create');
}
$isCurrent = (int) ($_POST['is_current'] ?? 0);
if ($isCurrent) {
$db->execute("UPDATE fiscal_years SET is_current = 0 WHERE is_current = 1");
}
$fy = FiscalYear::create([
'name_ar' => $data['name_ar'],
'name_en' => $data['name_en'],
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'is_current' => $isCurrent,
'status' => 'open',
'notes' => $_POST['notes'] ?? null,
]);
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء السنة المالية بنجاح']]);
return $this->redirect('/accounting/fiscal-years');
}
public function show(int $id): Response
{
$this->authorize('accounting.fiscal_year.view');
$fy = FiscalYear::findOrFail($id);
$periods = PeriodClosingService::getPeriodSummary($id);
return $this->view('Accounting/Views/fiscal_years/show', [
'fiscal_year' => $fy->toArray(),
'periods' => $periods,
]);
}
public function close(int $id): Response
{
$this->authorize('accounting.fiscal_year.close');
$result = PeriodClosingService::closeFiscalYear($id);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إقفال السنة المالية — صافي الربح: ' . $result['net_income'] . ' ج.م']]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/fiscal-years/' . $id);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\JournalEntry;
use App\Modules\Accounting\Models\JournalEntryLine;
use App\Modules\Accounting\Models\Account;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Models\CostCenter;
use App\Modules\Accounting\Services\JournalService;
class JournalEntryController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.journal.view');
$page = max(1, (int) ($_GET['page'] ?? 1));
$filters = [
'status' => $_GET['status'] ?? null,
'date_from' => $_GET['date_from'] ?? null,
'date_to' => $_GET['date_to'] ?? null,
'source_module' => $_GET['source_module'] ?? null,
'fiscal_year_id' => $_GET['fiscal_year_id'] ?? null,
'search' => $_GET['search'] ?? null,
];
$result = JournalEntry::search($filters, $page);
$fiscalYears = FiscalYear::query()
->where('is_archived', '=', 0)
->orderBy('start_date', 'DESC')
->get();
return $this->view('Accounting/Views/journal_entries/index', [
'entries' => $result['data'],
'total' => $result['total'],
'page' => $result['page'],
'pages' => $result['pages'],
'filters' => $filters,
'fiscal_years' => $fiscalYears,
]);
}
public function create(): Response
{
$this->authorize('accounting.journal.create');
$accounts = Account::getPostableAccounts();
$costCenters = CostCenter::getActive();
$currentFY = FiscalYear::current();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/journal_entries/create', [
'accounts' => $accounts,
'cost_centers' => $costCenters,
'fiscal_year' => $currentFY ? $currentFY->toArray() : null,
'branches' => $branches,
]);
}
public function store(): Response
{
$this->authorize('accounting.journal.create');
$data = $this->validate($_POST, [
'entry_date' => 'required',
'description_ar' => 'required',
]);
// Parse lines from POST
$lines = [];
$lineAccounts = $_POST['line_account_id'] ?? [];
$lineDebits = $_POST['line_debit'] ?? [];
$lineCredits = $_POST['line_credit'] ?? [];
$lineDescriptions = $_POST['line_description'] ?? [];
for ($i = 0; $i < count($lineAccounts); $i++) {
if (empty($lineAccounts[$i])) {
continue;
}
$lines[] = [
'account_id' => (int) $lineAccounts[$i],
'debit' => $lineDebits[$i] ?? '0.00',
'credit' => $lineCredits[$i] ?? '0.00',
'description_ar' => $lineDescriptions[$i] ?? null,
'cost_center_id' => !empty($_POST['line_cost_center_id'][$i]) ? (int) $_POST['line_cost_center_id'][$i] : null,
'branch_id' => !empty($_POST['line_branch_id'][$i]) ? (int) $_POST['line_branch_id'][$i] : null,
];
}
$result = JournalService::createEntry([
'entry_date' => $data['entry_date'],
'description_ar' => $data['description_ar'],
'description_en' => $_POST['description_en'] ?? null,
'reference_type' => 'manual',
'reference_number' => $_POST['reference_number'] ?? null,
'branch_id' => !empty($_POST['branch_id']) ? (int) $_POST['branch_id'] : null,
'cost_center_id' => !empty($_POST['cost_center_id']) ? (int) $_POST['cost_center_id'] : null,
'notes' => $_POST['notes'] ?? null,
], $lines, false);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إنشاء القيد ' . $result['entry_number'] . ' بنجاح']]);
return $this->redirect('/accounting/journal-entries/' . $result['journal_entry_id']);
}
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
return $this->redirect('/accounting/journal-entries/create');
}
public function show(int $id): Response
{
$this->authorize('accounting.journal.view');
$entry = JournalEntry::findOrFail($id);
$lines = JournalEntryLine::getByJournalEntry($id);
// Get employee names
$db = App::getInstance()->db();
$createdBy = null;
if ($entry->created_by) {
$createdBy = $db->selectOne("SELECT full_name_ar FROM employees WHERE id = ?", [(int) $entry->created_by]);
}
$postedBy = null;
if ($entry->posted_by) {
$postedBy = $db->selectOne("SELECT full_name_ar FROM employees WHERE id = ?", [(int) $entry->posted_by]);
}
return $this->view('Accounting/Views/journal_entries/show', [
'entry' => $entry->toArray(),
'lines' => $lines,
'created_by' => $createdBy['full_name_ar'] ?? '—',
'posted_by' => $postedBy['full_name_ar'] ?? '—',
]);
}
public function post(int $id): Response
{
$this->authorize('accounting.journal.post');
$result = JournalService::postEntry($id);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم ترحيل القيد بنجاح']]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/journal-entries/' . $id);
}
public function reverse(int $id): Response
{
$this->authorize('accounting.journal.reverse');
$reason = $_POST['reason'] ?? 'عكس قيد';
$result = JournalService::reverseEntry($id, $reason);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم عكس القيد — القيد العكسي: ' . $result['reversal_number']]]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/journal-entries/' . $id);
}
public function searchAccounts(): Response
{
$this->authorize('accounting.journal.view');
$term = $_GET['q'] ?? '';
$results = Account::search($term);
return $this->json($results);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Services\PeriodClosingService;
class PeriodClosingController extends Controller
{
public function index(): Response
{
$this->authorize('accounting.period.view');
$currentFY = FiscalYear::current();
$fiscalYearId = (int) ($_GET['fiscal_year_id'] ?? ($currentFY ? $currentFY->id : 0));
$periods = [];
$fiscalYear = null;
if ($fiscalYearId > 0) {
$fiscalYear = FiscalYear::find($fiscalYearId);
$periods = PeriodClosingService::getPeriodSummary($fiscalYearId);
}
$fiscalYears = FiscalYear::query()
->where('is_archived', '=', 0)
->orderBy('start_date', 'DESC')
->get();
return $this->view('Accounting/Views/period_closing/index', [
'periods' => $periods,
'fiscal_year' => $fiscalYear ? $fiscalYear->toArray() : null,
'fiscal_years' => $fiscalYears,
'fiscal_year_id' => $fiscalYearId,
]);
}
public function closeMonth(): Response
{
$this->authorize('accounting.period.close');
$fiscalYearId = (int) ($_POST['fiscal_year_id'] ?? 0);
$period = $_POST['period'] ?? '';
if ($fiscalYearId <= 0 || empty($period)) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'بيانات غير مكتملة']]);
return $this->redirect('/accounting/period-closing');
}
$result = PeriodClosingService::closeMonth($fiscalYearId, $period);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إغلاق الفترة ' . $period . ' بنجاح']]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/period-closing?fiscal_year_id=' . $fiscalYearId);
}
public function reopenMonth(): Response
{
$this->authorize('accounting.period.reopen');
$fiscalYearId = (int) ($_POST['fiscal_year_id'] ?? 0);
$period = $_POST['period'] ?? '';
$reason = $_POST['reason'] ?? '';
if ($fiscalYearId <= 0 || empty($period) || empty($reason)) {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'يجب تقديم سبب إعادة الفتح']]);
return $this->redirect('/accounting/period-closing');
}
$result = PeriodClosingService::reopenMonth($fiscalYearId, $period, $reason);
$session = App::getInstance()->session();
if ($result['success']) {
$session->flash('_alerts', [['type' => 'success', 'message' => 'تم إعادة فتح الفترة ' . $period]]);
} else {
$session->flash('_alerts', [['type' => 'error', 'message' => $result['error']]]);
}
return $this->redirect('/accounting/period-closing?fiscal_year_id=' . $fiscalYearId);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Controllers;
use App\Core\Controller;
use App\Core\App;
use App\Core\Response;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Models\CostCenter;
use App\Modules\Accounting\Models\AccountPayable;
use App\Modules\Accounting\Models\AccountReceivable;
use App\Modules\Accounting\Services\LedgerService;
use App\Modules\Accounting\Services\FinancialReportService;
class ReportController extends Controller
{
public function dashboard(): Response
{
$this->authorize('accounting.reports.view');
$db = App::getInstance()->db();
$currentFY = FiscalYear::current();
// Quick stats
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$monthlyTotals = $db->selectOne(
"SELECT COALESCE(SUM(total_debit), 0) as total_debit, COUNT(*) as count
FROM journal_entries
WHERE status = 'posted' AND entry_date >= ? AND entry_date <= ? AND is_archived = 0",
[$monthStart, $today]
);
$arSummary = AccountReceivable::getAgingSummary();
$apSummary = AccountPayable::getAgingSummary();
$draftCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM journal_entries WHERE status = 'draft' AND is_archived = 0"
);
return $this->view('Accounting/Views/dashboard/index', [
'fiscal_year' => $currentFY ? $currentFY->toArray() : null,
'monthly_totals' => $monthlyTotals,
'ar_summary' => $arSummary,
'ap_summary' => $apSummary,
'draft_count' => (int) ($draftCount['cnt'] ?? 0),
]);
}
public function trialBalance(): Response
{
$this->authorize('accounting.reports.trial_balance');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$result = LedgerService::getTrialBalance($dateFrom, $dateTo, null, $costCenterId, $branchId);
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/trial_balance', [
'result' => $result,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
],
]);
}
public function generalLedger(): Response
{
$this->authorize('accounting.reports.general_ledger');
$accountId = (int) ($_GET['account_id'] ?? 0);
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$ledger = null;
if ($accountId > 0) {
$ledger = LedgerService::getAccountLedger($accountId, $dateFrom, $dateTo, $costCenterId, $branchId);
}
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/general_ledger', [
'ledger' => $ledger,
'account_id' => $accountId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
],
]);
}
public function incomeStatement(): Response
{
$this->authorize('accounting.reports.income_statement');
$currentFY = FiscalYear::current();
$dateFrom = $_GET['date_from'] ?? ($currentFY ? $currentFY->start_date : date('Y-01-01'));
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$result = FinancialReportService::getIncomeStatement($dateFrom, $dateTo, $costCenterId, $branchId);
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/income_statement', [
'result' => $result,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
],
]);
}
public function balanceSheet(): Response
{
$this->authorize('accounting.reports.balance_sheet');
$asOfDate = $_GET['as_of_date'] ?? date('Y-m-d');
$costCenterId = !empty($_GET['cost_center_id']) ? (int) $_GET['cost_center_id'] : null;
$branchId = !empty($_GET['branch_id']) ? (int) $_GET['branch_id'] : null;
$result = FinancialReportService::getBalanceSheet($asOfDate, $costCenterId, $branchId);
$costCenters = CostCenter::getActive();
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_archived = 0 ORDER BY name_ar");
return $this->view('Accounting/Views/reports/balance_sheet', [
'result' => $result,
'as_of_date' => $asOfDate,
'cost_centers' => $costCenters,
'branches' => $branches,
'filters' => [
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
],
]);
}
public function consolidatedBalanceSheet(): Response
{
$this->authorize('accounting.reports.consolidated');
$asOfDate = $_GET['as_of_date'] ?? date('Y-m-d');
$result = FinancialReportService::getConsolidatedBalanceSheet($asOfDate);
return $this->view('Accounting/Views/reports/consolidated_balance_sheet', [
'result' => $result,
'as_of_date' => $asOfDate,
]);
}
public function accountsReceivable(): Response
{
$this->authorize('accounting.reports.ar');
$memberId = !empty($_GET['member_id']) ? (int) $_GET['member_id'] : null;
$outstanding = AccountReceivable::getOutstanding($memberId);
$aging = AccountReceivable::getAgingSummary();
return $this->view('Accounting/Views/reports/accounts_receivable', [
'outstanding' => $outstanding,
'aging' => $aging,
'member_id' => $memberId,
]);
}
public function accountsPayable(): Response
{
$this->authorize('accounting.reports.ap');
$supplierId = !empty($_GET['supplier_id']) ? (int) $_GET['supplier_id'] : null;
$outstanding = AccountPayable::getOutstanding($supplierId);
$aging = AccountPayable::getAgingSummary();
return $this->view('Accounting/Views/reports/accounts_payable', [
'outstanding' => $outstanding,
'aging' => $aging,
'supplier_id' => $supplierId,
]);
}
public function memberStatement(): Response
{
$this->authorize('accounting.reports.member_statement');
$memberId = (int) ($_GET['member_id'] ?? 0);
$dateFrom = $_GET['date_from'] ?? date('Y-01-01');
$dateTo = $_GET['date_to'] ?? date('Y-m-d');
$entries = [];
$member = null;
if ($memberId > 0) {
$db = App::getInstance()->db();
$member = $db->selectOne("SELECT id, full_name_ar, form_number FROM members WHERE id = ?", [$memberId]);
$entries = LedgerService::getMemberStatement($memberId, $dateFrom, $dateTo);
}
return $this->view('Accounting/Views/reports/member_statement', [
'member' => $member,
'entries' => $entries,
'member_id' => $memberId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class Account extends Model
{
protected static string $table = 'chart_of_accounts';
protected static array $fillable = [
'account_code', 'name_ar', 'name_en', 'account_type', 'account_nature',
'parent_id', 'level', 'is_header', 'is_active', 'is_system',
'description_ar', 'description_en', 'opening_balance', 'current_balance',
'currency', 'cost_center_id', 'is_bank_account', 'bank_account_id',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function findByCode(string $code): ?static
{
$row = static::query()->where('account_code', '=', $code)->where('is_archived', '=', 0)->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function getByType(string $type): array
{
return static::query()
->where('account_type', '=', $type)
->where('is_active', '=', 1)
->where('is_archived', '=', 0)
->orderBy('account_code', 'ASC')
->get();
}
public static function getPostableAccounts(): array
{
return static::query()
->where('is_header', '=', 0)
->where('is_active', '=', 1)
->where('is_archived', '=', 0)
->orderBy('account_code', 'ASC')
->get();
}
public function children(): array
{
return static::query()
->where('parent_id', '=', $this->id)
->where('is_archived', '=', 0)
->orderBy('account_code', 'ASC')
->get();
}
public function parent(): ?array
{
if ($this->parent_id === null) {
return null;
}
return static::query()->where('id', '=', $this->parent_id)->first();
}
public static function getTree(): array
{
$all = static::query()
->where('is_archived', '=', 0)
->orderBy('account_code', 'ASC')
->get();
return self::buildTree($all, null);
}
private static function buildTree(array $accounts, ?int $parentId): array
{
$tree = [];
foreach ($accounts as $account) {
$pid = $account['parent_id'] !== null ? (int) $account['parent_id'] : null;
if ($pid === $parentId) {
$account['children'] = self::buildTree($accounts, (int) $account['id']);
$tree[] = $account;
}
}
return $tree;
}
public static function search(string $term): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM chart_of_accounts
WHERE is_archived = 0
AND (account_code LIKE ? OR name_ar LIKE ? OR name_en LIKE ?)
ORDER BY account_code ASC
LIMIT 50",
["%{$term}%", "%{$term}%", "%{$term}%"]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class AccountBalance extends Model
{
protected static string $table = 'account_balances';
protected static array $fillable = [
'account_id', 'fiscal_year_id', 'period',
'opening_debit', 'opening_credit', 'period_debit', 'period_credit',
'closing_debit', 'closing_credit', 'cost_center_id', 'branch_id',
'is_closed',
];
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static bool $dispatchEvents = false;
public static function getForPeriod(int $fiscalYearId, string $period, ?int $costCenterId = null, ?int $branchId = null): array
{
$db = App::getInstance()->db();
$where = 'ab.fiscal_year_id = ? AND ab.period = ?';
$params = [$fiscalYearId, $period];
if ($costCenterId !== null) {
$where .= ' AND ab.cost_center_id = ?';
$params[] = $costCenterId;
}
if ($branchId !== null) {
$where .= ' AND ab.branch_id = ?';
$params[] = $branchId;
}
return $db->select(
"SELECT ab.*, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.level, coa.is_header
FROM account_balances ab
JOIN chart_of_accounts coa ON coa.id = ab.account_id
WHERE {$where}
ORDER BY coa.account_code ASC",
$params
);
}
public static function upsert(array $data): void
{
$db = App::getInstance()->db();
$db->execute(
"INSERT INTO account_balances
(account_id, fiscal_year_id, period, opening_debit, opening_credit,
period_debit, period_credit, closing_debit, closing_credit,
cost_center_id, branch_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
period_debit = VALUES(period_debit),
period_credit = VALUES(period_credit),
closing_debit = VALUES(closing_debit),
closing_credit = VALUES(closing_credit),
updated_at = NOW()",
[
$data['account_id'], $data['fiscal_year_id'], $data['period'],
$data['opening_debit'] ?? '0.00', $data['opening_credit'] ?? '0.00',
$data['period_debit'] ?? '0.00', $data['period_credit'] ?? '0.00',
$data['closing_debit'] ?? '0.00', $data['closing_credit'] ?? '0.00',
$data['cost_center_id'] ?? null, $data['branch_id'] ?? null,
]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class AccountPayable extends Model
{
protected static string $table = 'accounts_payable';
protected static array $fillable = [
'supplier_id', 'invoice_number', 'invoice_date', 'due_date',
'description_ar', 'description_en', 'total_amount', 'paid_amount',
'balance', 'currency', 'status', 'journal_entry_id', 'payment_entry_id',
'purchase_order_id', 'cost_center_id', 'branch_id', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function getOutstanding(?int $supplierId = null): array
{
$db = App::getInstance()->db();
$where = "ap.is_archived = 0 AND ap.status IN ('pending','partial','overdue')";
$params = [];
if ($supplierId !== null) {
$where .= ' AND ap.supplier_id = ?';
$params[] = $supplierId;
}
return $db->select(
"SELECT ap.*, s.name as supplier_name
FROM accounts_payable ap
LEFT JOIN suppliers s ON s.id = ap.supplier_id
WHERE {$where}
ORDER BY ap.due_date ASC",
$params
);
}
public static function getAgingSummary(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
return $db->selectOne(
"SELECT
SUM(CASE WHEN due_date >= ? THEN balance ELSE 0 END) as current_amount,
SUM(CASE WHEN due_date < ? AND due_date >= DATE_SUB(?, INTERVAL 30 DAY) THEN balance ELSE 0 END) as days_30,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 30 DAY) AND due_date >= DATE_SUB(?, INTERVAL 60 DAY) THEN balance ELSE 0 END) as days_60,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 60 DAY) AND due_date >= DATE_SUB(?, INTERVAL 90 DAY) THEN balance ELSE 0 END) as days_90,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 90 DAY) THEN balance ELSE 0 END) as over_90,
SUM(balance) as total
FROM accounts_payable
WHERE is_archived = 0 AND status IN ('pending','partial','overdue')",
[$today, $today, $today, $today, $today, $today, $today, $today]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class AccountReceivable extends Model
{
protected static string $table = 'accounts_receivable';
protected static array $fillable = [
'member_id', 'document_type', 'document_id', 'document_number',
'document_date', 'due_date', 'description_ar', 'description_en',
'total_amount', 'paid_amount', 'balance', 'currency', 'status',
'journal_entry_id', 'payment_entry_id', 'cost_center_id', 'branch_id', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function getOutstanding(?int $memberId = null): array
{
$db = App::getInstance()->db();
$where = "ar.is_archived = 0 AND ar.status IN ('pending','partial','overdue')";
$params = [];
if ($memberId !== null) {
$where .= ' AND ar.member_id = ?';
$params[] = $memberId;
}
return $db->select(
"SELECT ar.*, m.full_name_ar as member_name, m.form_number
FROM accounts_receivable ar
LEFT JOIN members m ON m.id = ar.member_id
WHERE {$where}
ORDER BY ar.due_date ASC",
$params
);
}
public static function getAgingSummary(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
return $db->selectOne(
"SELECT
SUM(CASE WHEN due_date >= ? THEN balance ELSE 0 END) as current_amount,
SUM(CASE WHEN due_date < ? AND due_date >= DATE_SUB(?, INTERVAL 30 DAY) THEN balance ELSE 0 END) as days_30,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 30 DAY) AND due_date >= DATE_SUB(?, INTERVAL 60 DAY) THEN balance ELSE 0 END) as days_60,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 60 DAY) AND due_date >= DATE_SUB(?, INTERVAL 90 DAY) THEN balance ELSE 0 END) as days_90,
SUM(CASE WHEN due_date < DATE_SUB(?, INTERVAL 90 DAY) THEN balance ELSE 0 END) as over_90,
SUM(balance) as total
FROM accounts_receivable
WHERE is_archived = 0 AND status IN ('pending','partial','overdue')",
[$today, $today, $today, $today, $today, $today, $today, $today]
);
}
public static function findByDocument(string $type, int $documentId): ?static
{
$row = static::query()
->where('document_type', '=', $type)
->where('document_id', '=', $documentId)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
class BankAccount extends Model
{
protected static string $table = 'bank_accounts';
protected static array $fillable = [
'account_name_ar', 'account_name_en', 'bank_name_ar', 'bank_name_en',
'account_number', 'iban', 'swift_code', 'branch_name', 'currency',
'gl_account_id', 'opening_balance', 'current_balance',
'is_default', 'is_active', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function getDefault(): ?static
{
$row = static::query()
->where('is_default', '=', 1)
->where('is_active', '=', 1)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function getActive(): array
{
return static::query()
->where('is_active', '=', 1)
->where('is_archived', '=', 0)
->orderBy('account_name_ar', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
class BankReconciliation extends Model
{
protected static string $table = 'bank_reconciliations';
protected static array $fillable = [
'bank_account_id', 'reconciliation_date', 'statement_date',
'statement_balance', 'book_balance', 'adjusted_balance', 'difference',
'status', 'approved_at', 'approved_by', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public function items(): array
{
return BankReconciliationItem::query()
->where('reconciliation_id', '=', $this->id)
->orderBy('id', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
class BankReconciliationItem extends Model
{
protected static string $table = 'bank_reconciliation_items';
protected static array $fillable = [
'reconciliation_id', 'item_type', 'description_ar', 'description_en',
'amount', 'transaction_date', 'reference_number', 'journal_entry_id',
'is_cleared', 'cleared_date',
];
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
class CostCenter extends Model
{
protected static string $table = 'cost_centers';
protected static array $fillable = [
'code', 'name_ar', 'name_en', 'type', 'branch_id',
'parent_id', 'is_active', 'description',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function findByCode(string $code): ?static
{
$row = static::query()->where('code', '=', $code)->where('is_archived', '=', 0)->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function getActive(): array
{
return static::query()
->where('is_active', '=', 1)
->where('is_archived', '=', 0)
->orderBy('code', 'ASC')
->get();
}
public static function findByBranch(int $branchId): ?static
{
$row = static::query()
->where('branch_id', '=', $branchId)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
class FiscalYear extends Model
{
protected static string $table = 'fiscal_years';
protected static array $fillable = [
'name_ar', 'name_en', 'start_date', 'end_date',
'status', 'is_current', 'closed_at', 'closed_by', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function current(): ?static
{
$row = static::query()->where('is_current', '=', 1)->where('is_archived', '=', 0)->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function findByDate(string $date): ?static
{
$row = static::query()
->where('start_date', '<=', $date)
->where('end_date', '>=', $date)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public function isOpen(): bool
{
return $this->status === 'open';
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class JournalEntry extends Model
{
protected static string $table = 'journal_entries';
protected static array $fillable = [
'entry_number', 'fiscal_year_id', 'entry_date', 'reference_type',
'reference_id', 'reference_number', 'description_ar', 'description_en',
'total_debit', 'total_credit', 'currency', 'status', 'posted_at',
'posted_by', 'reversed_entry_id', 'reversal_of_id', 'is_auto_generated',
'source_module', 'branch_id', 'cost_center_id', 'period_closed',
'attachments', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public function lines(): array
{
return JournalEntryLine::query()
->where('journal_entry_id', '=', $this->id)
->orderBy('line_number', 'ASC')
->get();
}
public static function generateEntryNumber(): string
{
$db = App::getInstance()->db();
$year = date('Y');
$prefix = 'JE-' . $year . '-';
$last = $db->selectOne(
"SELECT entry_number FROM journal_entries WHERE entry_number LIKE ? ORDER BY id DESC LIMIT 1",
[$prefix . '%']
);
if ($last) {
$parts = explode('-', $last['entry_number']);
$seq = (int) end($parts) + 1;
} else {
$seq = 1;
}
return $prefix . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
public function isPosted(): bool
{
return $this->status === 'posted';
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
public function isReversed(): bool
{
return $this->status === 'reversed';
}
public static function findByReference(string $type, int $id): ?static
{
$row = static::query()
->where('reference_type', '=', $type)
->where('reference_id', '=', $id)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function search(array $filters = [], int $page = 1, int $perPage = 25): array
{
$db = App::getInstance()->db();
$where = ['je.is_archived = 0'];
$params = [];
if (!empty($filters['status'])) {
$where[] = 'je.status = ?';
$params[] = $filters['status'];
}
if (!empty($filters['date_from'])) {
$where[] = 'je.entry_date >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where[] = 'je.entry_date <= ?';
$params[] = $filters['date_to'];
}
if (!empty($filters['source_module'])) {
$where[] = 'je.source_module = ?';
$params[] = $filters['source_module'];
}
if (!empty($filters['reference_type'])) {
$where[] = 'je.reference_type = ?';
$params[] = $filters['reference_type'];
}
if (!empty($filters['fiscal_year_id'])) {
$where[] = 'je.fiscal_year_id = ?';
$params[] = $filters['fiscal_year_id'];
}
if (!empty($filters['search'])) {
$where[] = '(je.entry_number LIKE ? OR je.description_ar LIKE ? OR je.description_en LIKE ? OR je.reference_number LIKE ?)';
$term = '%' . $filters['search'] . '%';
$params = array_merge($params, [$term, $term, $term, $term]);
}
$whereClause = implode(' AND ', $where);
$offset = ($page - 1) * $perPage;
$countRow = $db->selectOne("SELECT COUNT(*) as total FROM journal_entries je WHERE {$whereClause}", $params);
$total = (int) ($countRow['total'] ?? 0);
$rows = $db->select(
"SELECT je.*, fy.name_ar as fiscal_year_name
FROM journal_entries je
LEFT JOIN fiscal_years fy ON fy.id = je.fiscal_year_id
WHERE {$whereClause}
ORDER BY je.entry_date DESC, je.id DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return [
'data' => $rows,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'pages' => (int) ceil($total / $perPage),
];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class JournalEntryLine extends Model
{
protected static string $table = 'journal_entry_lines';
protected static array $fillable = [
'journal_entry_id', 'line_number', 'account_id', 'description_ar',
'description_en', 'debit', 'credit', 'currency', 'cost_center_id',
'branch_id', 'member_id', 'employee_id', 'supplier_id',
'reference_type', 'reference_id', 'notes',
];
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
public function account(): ?array
{
return App::getInstance()->db()->selectOne(
"SELECT * FROM chart_of_accounts WHERE id = ?",
[$this->account_id]
);
}
public static function getByJournalEntry(int $journalEntryId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT jel.*, coa.account_code, coa.name_ar as account_name_ar, coa.name_en as account_name_en
FROM journal_entry_lines jel
JOIN chart_of_accounts coa ON coa.id = jel.account_id
WHERE jel.journal_entry_id = ?
ORDER BY jel.line_number ASC",
[$journalEntryId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Models;
use App\Core\Model;
use App\Core\App;
class PeriodClosing extends Model
{
protected static string $table = 'period_closings';
protected static array $fillable = [
'fiscal_year_id', 'period', 'period_start', 'period_end',
'closing_type', 'status', 'total_debit', 'total_credit',
'journal_count', 'closing_entry_id', 'closed_at', 'closed_by',
'reopened_at', 'reopened_by', 'reopen_reason', 'notes',
];
protected static bool $timestamps = true;
protected static bool $softDelete = true;
public static function findByPeriod(int $fiscalYearId, string $period): ?static
{
$row = static::query()
->where('fiscal_year_id', '=', $fiscalYearId)
->where('period', '=', $period)
->where('is_archived', '=', 0)
->first();
if ($row === null) {
return null;
}
$instance = new static($row);
$instance->exists = true;
return $instance;
}
public static function isPeriodClosed(int $fiscalYearId, string $period): bool
{
$pc = self::findByPeriod($fiscalYearId, $period);
return $pc !== null && $pc->status === 'closed';
}
public static function getForFiscalYear(int $fiscalYearId): array
{
return static::query()
->where('fiscal_year_id', '=', $fiscalYearId)
->where('is_archived', '=', 0)
->orderBy('period', 'ASC')
->get();
}
}
<?php
declare(strict_types=1);
return [
// ── Dashboard ────────────────────────────────────────────
['GET', '/accounting', 'Accounting\Controllers\ReportController@dashboard', ['auth'], 'accounting.reports.view'],
// ── Fiscal Years ─────────────────────────────────────────
['GET', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@index', ['auth'], 'accounting.fiscal_year.view'],
['GET', '/accounting/fiscal-years/create', 'Accounting\Controllers\FiscalYearController@create', ['auth'], 'accounting.fiscal_year.manage'],
['POST', '/accounting/fiscal-years', 'Accounting\Controllers\FiscalYearController@store', ['auth'], 'accounting.fiscal_year.manage'],
['GET', '/accounting/fiscal-years/{id:\d+}', 'Accounting\Controllers\FiscalYearController@show', ['auth'], 'accounting.fiscal_year.view'],
['POST', '/accounting/fiscal-years/{id:\d+}/close', 'Accounting\Controllers\FiscalYearController@close', ['auth'], 'accounting.fiscal_year.close'],
// ── Chart of Accounts ────────────────────────────────────
['GET', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@index', ['auth'], 'accounting.coa.view'],
['GET', '/accounting/chart-of-accounts/create', 'Accounting\Controllers\ChartOfAccountsController@create', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts', 'Accounting\Controllers\ChartOfAccountsController@store', ['auth'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/{id:\d+}/edit', 'Accounting\Controllers\ChartOfAccountsController@edit', ['auth'], 'accounting.coa.manage'],
['POST', '/accounting/chart-of-accounts/{id:\d+}', 'Accounting\Controllers\ChartOfAccountsController@update', ['auth'], 'accounting.coa.manage'],
['GET', '/accounting/chart-of-accounts/search', 'Accounting\Controllers\ChartOfAccountsController@search', ['auth'], 'accounting.coa.view'],
// ── Cost Centers ─────────────────────────────────────────
['GET', '/accounting/cost-centers', 'Accounting\Controllers\CostCenterController@index', ['auth'], 'accounting.cost_center.view'],
['GET', '/accounting/cost-centers/create', 'Accounting\Controllers\CostCenterController@create', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers', 'Accounting\Controllers\CostCenterController@store', ['auth'], 'accounting.cost_center.manage'],
['GET', '/accounting/cost-centers/{id:\d+}/edit', 'Accounting\Controllers\CostCenterController@edit', ['auth'], 'accounting.cost_center.manage'],
['POST', '/accounting/cost-centers/{id:\d+}', 'Accounting\Controllers\CostCenterController@update', ['auth'], 'accounting.cost_center.manage'],
// ── Bank Accounts ────────────────────────────────────────
['GET', '/accounting/bank-accounts', 'Accounting\Controllers\BankAccountController@index', ['auth'], 'accounting.bank_account.view'],
['GET', '/accounting/bank-accounts/create', 'Accounting\Controllers\BankAccountController@create', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts', 'Accounting\Controllers\BankAccountController@store', ['auth'], 'accounting.bank_account.manage'],
['GET', '/accounting/bank-accounts/{id:\d+}/edit', 'Accounting\Controllers\BankAccountController@edit', ['auth'], 'accounting.bank_account.manage'],
['POST', '/accounting/bank-accounts/{id:\d+}', 'Accounting\Controllers\BankAccountController@update', ['auth'], 'accounting.bank_account.manage'],
// ── Journal Entries ──────────────────────────────────────
['GET', '/accounting/journal-entries', 'Accounting\Controllers\JournalEntryController@index', ['auth'], 'accounting.journal.view'],
['GET', '/accounting/journal-entries/create', 'Accounting\Controllers\JournalEntryController@create', ['auth'], 'accounting.journal.create'],
['POST', '/accounting/journal-entries', 'Accounting\Controllers\JournalEntryController@store', ['auth'], 'accounting.journal.create'],
['GET', '/accounting/journal-entries/{id:\d+}', 'Accounting\Controllers\JournalEntryController@show', ['auth'], 'accounting.journal.view'],
['POST', '/accounting/journal-entries/{id:\d+}/post', 'Accounting\Controllers\JournalEntryController@post', ['auth'], 'accounting.journal.post'],
['POST', '/accounting/journal-entries/{id:\d+}/reverse', 'Accounting\Controllers\JournalEntryController@reverse', ['auth'], 'accounting.journal.reverse'],
['GET', '/accounting/journal-entries/search-accounts', 'Accounting\Controllers\JournalEntryController@searchAccounts', ['auth'], 'accounting.journal.view'],
// ── Bank Reconciliation ──────────────────────────────────
['GET', '/accounting/bank-reconciliation', 'Accounting\Controllers\BankReconciliationController@index', ['auth'], 'accounting.bank_recon.view'],
['GET', '/accounting/bank-reconciliation/create', 'Accounting\Controllers\BankReconciliationController@create', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation', 'Accounting\Controllers\BankReconciliationController@store', ['auth'], 'accounting.bank_recon.manage'],
['GET', '/accounting/bank-reconciliation/{id:\d+}', 'Accounting\Controllers\BankReconciliationController@show', ['auth'], 'accounting.bank_recon.view'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/add-item', 'Accounting\Controllers\BankReconciliationController@addItem', ['auth'], 'accounting.bank_recon.manage'],
['POST', '/accounting/bank-reconciliation/{id:\d+}/complete', 'Accounting\Controllers\BankReconciliationController@complete', ['auth'], 'accounting.bank_recon.manage'],
// ── Period Closing ───────────────────────────────────────
['GET', '/accounting/period-closing', 'Accounting\Controllers\PeriodClosingController@index', ['auth'], 'accounting.period.view'],
['POST', '/accounting/period-closing/close-month', 'Accounting\Controllers\PeriodClosingController@closeMonth', ['auth'], 'accounting.period.close'],
['POST', '/accounting/period-closing/reopen-month', 'Accounting\Controllers\PeriodClosingController@reopenMonth', ['auth'], 'accounting.period.reopen'],
// ── Reports ──────────────────────────────────────────────
['GET', '/accounting/reports/trial-balance', 'Accounting\Controllers\ReportController@trialBalance', ['auth'], 'accounting.reports.trial_balance'],
['GET', '/accounting/reports/general-ledger', 'Accounting\Controllers\ReportController@generalLedger', ['auth'], 'accounting.reports.general_ledger'],
['GET', '/accounting/reports/income-statement', 'Accounting\Controllers\ReportController@incomeStatement', ['auth'], 'accounting.reports.income_statement'],
['GET', '/accounting/reports/balance-sheet', 'Accounting\Controllers\ReportController@balanceSheet', ['auth'], 'accounting.reports.balance_sheet'],
['GET', '/accounting/reports/consolidated-balance-sheet', 'Accounting\Controllers\ReportController@consolidatedBalanceSheet', ['auth'], 'accounting.reports.consolidated'],
['GET', '/accounting/reports/accounts-receivable', 'Accounting\Controllers\ReportController@accountsReceivable', ['auth'], 'accounting.reports.ar'],
['GET', '/accounting/reports/accounts-payable', 'Accounting\Controllers\ReportController@accountsPayable', ['auth'], 'accounting.reports.ap'],
['GET', '/accounting/reports/member-statement', 'Accounting\Controllers\ReportController@memberStatement', ['auth'], 'accounting.reports.member_statement'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
/**
* Auto-posting journal entries from other modules.
* Each method is called from bootstrap.php event listeners.
*
* Account codes used (Egyptian standard):
* 1101 - Cash (نقدية بالصندوق)
* 1102 - Bank (نقدية بالبنك)
* 1103 - Accounts Receivable (حسابات مدينة)
* 1104 - Inventory (مخزون)
* 2101 - Accounts Payable (حسابات دائنة)
* 2103 - Tax Payable (ضرائب مستحقة)
* 2104 - Insurance Payable (تأمينات مستحقة)
* 2105 - Deferred Revenue (إيرادات مؤجلة)
* 4101 - Membership Revenue (إيرادات عضوية)
* 4102 - Subscription Revenue (إيرادات اشتراكات)
* 4103 - Sales Revenue (إيرادات مبيعات)
* 4104 - Fine Revenue (إيرادات غرامات)
* 4105 - Service Revenue (إيرادات خدمات)
* 5101 - Salary Expense (مصروفات رواتب)
* 5102 - Insurance Expense (مصروفات تأمينات — حصة صاحب العمل)
* 5106 - Cost of Goods Sold (تكلفة البضاعة المباعة)
*/
final class AccountingIntegrationService
{
// ────────────────────────────────────────────────────────────
// PAYMENTS MODULE
// ────────────────────────────────────────────────────────────
/**
* Auto-post journal entry when a payment is completed.
* Dr. Cash/Bank | Cr. Revenue (type-dependent)
*
* @param array $data From 'payment.completed' event
*/
public static function onPaymentCompleted(array $data): void
{
$db = App::getInstance()->db();
$paymentId = (int) ($data['payment_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0.00');
$method = $data['method'] ?? 'cash';
$type = $data['type'] ?? '';
$memberId = (int) ($data['member_id'] ?? 0);
if (bccomp($amount, '0.00', 2) <= 0) {
return;
}
// Determine debit account (where money goes)
$debitAccountCode = match ($method) {
'cash' => '1101',
'check' => '1102',
'visa' => '1102',
'bank_transfer' => '1102',
default => '1101',
};
// Determine credit account (revenue type)
$creditAccountCode = match ($type) {
'form_fee', 'membership_fee', 'addition_fee', 'separation_fee',
'divorce_fee', 'death_fee', 'waiver_fee', 'sports_conversion',
'carnet_replacement', 'seasonal_fee'
=> '4101', // Membership Revenue
'annual_subscription', 'development_fee'
=> '4102', // Subscription Revenue
'down_payment', 'installment'
=> '1103', // Accounts Receivable (reducing AR)
'fine'
=> '4104', // Fine Revenue
'inventory_sale'
=> '4103', // Sales Revenue
default
=> '4105', // Service Revenue
};
$debitAccount = self::getAccountByCode($debitAccountCode);
$creditAccount = self::getAccountByCode($creditAccountCode);
if (!$debitAccount || !$creditAccount) {
Logger::error("Accounting auto-post failed: account not found", [
'debit_code' => $debitAccountCode,
'credit_code' => $creditAccountCode,
'payment_id' => $paymentId,
]);
return;
}
$payment = $db->selectOne("SELECT * FROM payments WHERE id = ?", [$paymentId]);
$receiptNumber = '';
if ($payment && $payment['receipt_id']) {
$receipt = $db->selectOne("SELECT receipt_number FROM receipts WHERE id = ?", [(int) $payment['receipt_id']]);
$receiptNumber = $receipt['receipt_number'] ?? '';
}
$typeLabel = self::getPaymentTypeLabel($type);
$description = 'تحصيل ' . $typeLabel;
if ($receiptNumber) {
$description .= ' — إيصال ' . $receiptNumber;
}
// For installment/down_payment, special handling
$lines = [
[
'account_id' => (int) $debitAccount['id'],
'debit' => $amount,
'credit' => '0.00',
'description_ar' => $description,
'member_id' => $memberId > 0 ? $memberId : null,
],
[
'account_id' => (int) $creditAccount['id'],
'debit' => '0.00',
'credit' => $amount,
'description_ar' => $description,
'member_id' => $memberId > 0 ? $memberId : null,
],
];
$result = JournalService::createEntry([
'entry_date' => $payment['payment_date'] ?? date('Y-m-d'),
'description_ar' => $description,
'description_en' => 'Payment collection — ' . $type,
'reference_type' => 'payment',
'reference_id' => $paymentId,
'reference_number' => $receiptNumber,
'source_module' => 'payments',
'is_auto_generated' => 1,
], $lines, true);
if (!$result['success']) {
Logger::error("Accounting auto-post failed for payment", [
'payment_id' => $paymentId,
'error' => $result['error'] ?? 'unknown',
]);
}
}
/**
* Auto-reverse journal entry when a payment is voided.
*/
public static function onPaymentVoided(array $data): void
{
$paymentId = (int) ($data['payment_id'] ?? 0);
$reason = $data['reason'] ?? 'إلغاء دفعة';
$entry = \App\Modules\Accounting\Models\JournalEntry::findByReference('payment', $paymentId);
if ($entry && $entry->isPosted()) {
JournalService::reverseEntry((int) $entry->id, $reason);
}
}
// ────────────────────────────────────────────────────────────
// SALES MODULE
// ────────────────────────────────────────────────────────────
/**
* Auto-post sales entry: Dr. Cash/Bank, Cr. Sales Revenue + COGS entry.
*/
public static function onSaleCompleted(array $data): void
{
$db = App::getInstance()->db();
$saleId = (int) ($data['sale_id'] ?? 0);
$totalAmount = (string) ($data['total_amount'] ?? '0.00');
$invoiceNumber = $data['invoice_number'] ?? '';
if (bccomp($totalAmount, '0.00', 2) <= 0) {
return;
}
$sale = $db->selectOne("SELECT * FROM sales WHERE id = ?", [$saleId]);
if (!$sale) {
return;
}
// Calculate total cost from sale items
$costRow = $db->selectOne(
"SELECT COALESCE(SUM(total_cost), 0) as total_cost FROM sale_items WHERE sale_id = ?",
[$saleId]
);
$totalCost = (string) ($costRow['total_cost'] ?? '0.00');
$salesRevenue = self::getAccountByCode('4103');
$cashAccount = self::getAccountByCode('1101');
$cogsAccount = self::getAccountByCode('5106');
$inventoryAccount = self::getAccountByCode('1104');
if (!$salesRevenue || !$cashAccount) {
return;
}
$description = 'مبيعات فاتورة رقم ' . $invoiceNumber;
// Revenue entry is handled by payment.completed, but COGS needs its own entry
if ($cogsAccount && $inventoryAccount && bccomp($totalCost, '0.00', 2) > 0) {
$cogsResult = JournalService::createEntry([
'entry_date' => $sale['sale_date'] ?? date('Y-m-d'),
'description_ar' => 'تكلفة بضاعة مباعة — فاتورة ' . $invoiceNumber,
'description_en' => 'COGS — Invoice ' . $invoiceNumber,
'reference_type' => 'sale_cogs',
'reference_id' => $saleId,
'reference_number' => $invoiceNumber,
'source_module' => 'sales',
'is_auto_generated' => 1,
], [
[
'account_id' => (int) $cogsAccount['id'],
'debit' => $totalCost,
'credit' => '0.00',
'description_ar' => 'تكلفة بضاعة مباعة',
],
[
'account_id' => (int) $inventoryAccount['id'],
'debit' => '0.00',
'credit' => $totalCost,
'description_ar' => 'خصم من المخزون',
],
], true);
if (!$cogsResult['success']) {
Logger::error("COGS auto-post failed", ['sale_id' => $saleId, 'error' => $cogsResult['error'] ?? '']);
}
}
}
/**
* Reverse sale journal entries when a sale is voided.
*/
public static function onSaleVoided(array $data): void
{
$saleId = (int) ($data['sale_id'] ?? 0);
$reason = $data['reason'] ?? 'إلغاء عملية بيع';
// Reverse COGS entry
$cogsEntry = \App\Modules\Accounting\Models\JournalEntry::findByReference('sale_cogs', $saleId);
if ($cogsEntry && $cogsEntry->isPosted()) {
JournalService::reverseEntry((int) $cogsEntry->id, $reason);
}
}
// ────────────────────────────────────────────────────────────
// HR PAYROLL MODULE
// ────────────────────────────────────────────────────────────
/**
* Auto-post payroll journal when payroll run is paid.
* Dr. Salary Expense (gross salaries)
* Dr. Insurance Expense (employer's insurance share)
* Cr. Bank (net salaries paid)
* Cr. Insurance Payable (employee + employer insurance)
* Cr. Tax Payable (income tax withheld)
*/
public static function onPayrollPaid(array $data): void
{
$db = App::getInstance()->db();
$payrollRunId = (int) ($data['payroll_run_id'] ?? 0);
if ($payrollRunId <= 0) {
return;
}
$run = $db->selectOne("SELECT * FROM hr_payroll_runs WHERE id = ?", [$payrollRunId]);
if (!$run) {
return;
}
// Get payroll component totals
$components = $db->select(
"SELECT component_type, SUM(amount) as total
FROM hr_payroll_components_log
WHERE payroll_run_id = ?
GROUP BY component_type",
[$payrollRunId]
);
$componentMap = [];
foreach ($components as $c) {
$componentMap[$c['component_type']] = (string) $c['total'];
}
$grossSalary = (string) ($run['total_gross'] ?? '0.00');
$netSalary = (string) ($run['total_net'] ?? '0.00');
$totalTax = $componentMap['income_tax'] ?? '0.00';
$empInsurance = $componentMap['social_insurance_employee'] ?? '0.00';
$erInsurance = $componentMap['social_insurance_employer'] ?? '0.00';
$totalInsurance = bcadd($empInsurance, $erInsurance, 2);
$salaryExpense = self::getAccountByCode('5101');
$insuranceExpense = self::getAccountByCode('5102');
$bankAccount = self::getAccountByCode('1102');
$insurancePayable = self::getAccountByCode('2104');
$taxPayable = self::getAccountByCode('2103');
if (!$salaryExpense || !$bankAccount) {
Logger::error("Payroll auto-post failed: core accounts not found");
return;
}
$period = $db->selectOne("SELECT * FROM hr_payroll_periods WHERE id = ?", [(int) $run['period_id']]);
$periodName = $period ? $period['period_name'] ?? $period['month'] . '/' . $period['year'] : '';
$lines = [];
// Dr. Salary Expense (gross)
$lines[] = [
'account_id' => (int) $salaryExpense['id'],
'debit' => $grossSalary,
'credit' => '0.00',
'description_ar' => 'مصروفات رواتب — ' . $periodName,
];
// Dr. Insurance Expense (employer share)
if ($insuranceExpense && bccomp($erInsurance, '0.00', 2) > 0) {
$lines[] = [
'account_id' => (int) $insuranceExpense['id'],
'debit' => $erInsurance,
'credit' => '0.00',
'description_ar' => 'حصة صاحب العمل في التأمينات — ' . $periodName,
];
}
// Cr. Bank (net salary)
$lines[] = [
'account_id' => (int) $bankAccount['id'],
'debit' => '0.00',
'credit' => $netSalary,
'description_ar' => 'صرف رواتب — ' . $periodName,
];
// Cr. Insurance Payable
if ($insurancePayable && bccomp($totalInsurance, '0.00', 2) > 0) {
$lines[] = [
'account_id' => (int) $insurancePayable['id'],
'debit' => '0.00',
'credit' => $totalInsurance,
'description_ar' => 'تأمينات مستحقة — ' . $periodName,
];
}
// Cr. Tax Payable
if ($taxPayable && bccomp($totalTax, '0.00', 2) > 0) {
$lines[] = [
'account_id' => (int) $taxPayable['id'],
'debit' => '0.00',
'credit' => $totalTax,
'description_ar' => 'ضرائب مستحقة — ' . $periodName,
];
}
// Verify double-entry balance before posting
$totalDebit = '0.00';
$totalCredit = '0.00';
foreach ($lines as $l) {
$totalDebit = bcadd($totalDebit, (string) $l['debit'], 2);
$totalCredit = bcadd($totalCredit, (string) $l['credit'], 2);
}
// If imbalanced due to rounding or missing components, adjust
$diff = bcsub($totalDebit, $totalCredit, 2);
if (bccomp($diff, '0.00', 2) !== 0) {
// Adjust bank line to balance
foreach ($lines as &$l) {
if ((int) $l['account_id'] === (int) $bankAccount['id']) {
$l['credit'] = bcadd((string) $l['credit'], $diff, 2);
break;
}
}
unset($l);
}
$result = JournalService::createEntry([
'entry_date' => $run['payment_date'] ?? date('Y-m-d'),
'description_ar' => 'قيد رواتب — ' . $periodName,
'description_en' => 'Payroll entry — ' . $periodName,
'reference_type' => 'payroll',
'reference_id' => $payrollRunId,
'source_module' => 'hr',
'is_auto_generated' => 1,
], $lines, true);
if (!$result['success']) {
Logger::error("Payroll auto-post failed", [
'payroll_run_id' => $payrollRunId,
'error' => $result['error'] ?? '',
]);
}
}
// ────────────────────────────────────────────────────────────
// SUBSCRIPTIONS MODULE
// ────────────────────────────────────────────────────────────
/**
* Auto-post subscription revenue when subscription is paid.
* Payment.completed handles the cash/bank side.
* This handles deferred revenue for annual subscriptions.
*/
public static function onSubscriptionPaid(array $data): void
{
// Revenue already posted through payment.completed event
// This is for additional tracking / deferred revenue if needed
$subscriptionId = (int) ($data['subscription_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0.00');
if ($subscriptionId <= 0 || bccomp($amount, '0.00', 2) <= 0) {
return;
}
// Update accounts receivable if exists
$ar = \App\Modules\Accounting\Models\AccountReceivable::findByDocument('subscription', $subscriptionId);
if ($ar) {
$newPaid = bcadd((string) $ar->paid_amount, $amount, 2);
$newBalance = bcsub((string) $ar->total_amount, $newPaid, 2);
$status = bccomp($newBalance, '0.00', 2) <= 0 ? 'paid' : 'partial';
$ar->update([
'paid_amount' => $newPaid,
'balance' => max('0.00', $newBalance),
'status' => $status,
]);
}
}
// ────────────────────────────────────────────────────────────
// FINES MODULE
// ────────────────────────────────────────────────────────────
/**
* Auto-post fine revenue when a fine is collected.
*/
public static function onFineCollected(array $data): void
{
$fineId = (int) ($data['fine_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0.00');
if ($fineId <= 0 || bccomp($amount, '0.00', 2) <= 0) {
return;
}
// Update accounts receivable for the fine
$ar = \App\Modules\Accounting\Models\AccountReceivable::findByDocument('fine', $fineId);
if ($ar) {
$newPaid = bcadd((string) $ar->paid_amount, $amount, 2);
$newBalance = bcsub((string) $ar->total_amount, $newPaid, 2);
$status = bccomp($newBalance, '0.00', 2) <= 0 ? 'paid' : 'partial';
$ar->update([
'paid_amount' => $newPaid,
'balance' => max('0.00', $newBalance),
'status' => $status,
]);
}
}
/**
* Create accounts receivable when a fine is imposed.
*/
public static function onFineImposed(array $data): void
{
$db = App::getInstance()->db();
$fineId = (int) ($data['fine_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
$amount = (string) ($data['amount'] ?? '0.00');
if ($fineId <= 0 || $memberId <= 0 || bccomp($amount, '0.00', 2) <= 0) {
return;
}
$fine = $db->selectOne("SELECT * FROM fines WHERE id = ?", [$fineId]);
if (!$fine) {
return;
}
// Create AR entry
$arAccount = self::getAccountByCode('1103');
$fineRevenue = self::getAccountByCode('4104');
if ($arAccount && $fineRevenue) {
// Create receivable journal entry
$result = JournalService::createEntry([
'entry_date' => $fine['imposed_date'] ?? date('Y-m-d'),
'description_ar' => 'استحقاق غرامة — عضو رقم ' . $memberId,
'reference_type' => 'fine',
'reference_id' => $fineId,
'source_module' => 'fines',
'is_auto_generated' => 1,
], [
[
'account_id' => (int) $arAccount['id'],
'debit' => $amount,
'credit' => '0.00',
'description_ar' => 'مدينون — غرامة',
'member_id' => $memberId,
],
[
'account_id' => (int) $fineRevenue['id'],
'debit' => '0.00',
'credit' => $amount,
'description_ar' => 'إيرادات غرامات',
],
], true);
// Create accounts receivable record
if ($result['success']) {
$db->insert('accounts_receivable', [
'member_id' => $memberId,
'document_type' => 'fine',
'document_id' => $fineId,
'document_date' => $fine['imposed_date'] ?? date('Y-m-d'),
'due_date' => date('Y-m-d', strtotime('+30 days')),
'description_ar' => 'غرامة مخالفة',
'total_amount' => $amount,
'paid_amount' => '0.00',
'balance' => $amount,
'status' => 'pending',
'journal_entry_id' => $result['journal_entry_id'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
// ────────────────────────────────────────────────────────────
// INSTALLMENTS MODULE
// ────────────────────────────────────────────────────────────
/**
* Create accounts receivable when an installment plan is created.
*/
public static function onInstallmentPlanCreated(array $data): void
{
$db = App::getInstance()->db();
$planId = (int) ($data['plan_id'] ?? 0);
$memberId = (int) ($data['member_id'] ?? 0);
$totalAmount = (string) ($data['total_amount'] ?? '0.00');
if ($planId <= 0 || bccomp($totalAmount, '0.00', 2) <= 0) {
return;
}
$plan = $db->selectOne("SELECT * FROM installment_plans WHERE id = ?", [$planId]);
if (!$plan) {
return;
}
$arAccount = self::getAccountByCode('1103');
$membershipRevenue = self::getAccountByCode('4101');
if ($arAccount && $membershipRevenue) {
// Journal: Dr. AR, Cr. Membership Revenue
$result = JournalService::createEntry([
'entry_date' => $plan['created_at'] ? substr($plan['created_at'], 0, 10) : date('Y-m-d'),
'description_ar' => 'استحقاق أقساط عضوية — عضو رقم ' . $memberId,
'reference_type' => 'installment_plan',
'reference_id' => $planId,
'source_module' => 'installments',
'is_auto_generated' => 1,
], [
[
'account_id' => (int) $arAccount['id'],
'debit' => $totalAmount,
'credit' => '0.00',
'description_ar' => 'مدينون — أقساط عضوية',
'member_id' => $memberId,
],
[
'account_id' => (int) $membershipRevenue['id'],
'debit' => '0.00',
'credit' => $totalAmount,
'description_ar' => 'إيرادات عضوية — أقساط',
],
], true);
if ($result['success']) {
$db->insert('accounts_receivable', [
'member_id' => $memberId,
'document_type' => 'installment',
'document_id' => $planId,
'document_date' => date('Y-m-d'),
'due_date' => $plan['end_date'] ?? date('Y-m-d', strtotime('+12 months')),
'description_ar' => 'أقساط عضوية',
'total_amount' => $totalAmount,
'paid_amount' => '0.00',
'balance' => $totalAmount,
'status' => 'pending',
'journal_entry_id' => $result['journal_entry_id'],
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
// ────────────────────────────────────────────────────────────
// HELPERS
// ────────────────────────────────────────────────────────────
private static function getAccountByCode(string $code): ?array
{
$db = App::getInstance()->db();
return $db->selectOne(
"SELECT id, account_code, name_ar, account_nature FROM chart_of_accounts WHERE account_code = ? AND is_archived = 0",
[$code]
);
}
private static function getPaymentTypeLabel(string $type): string
{
return match ($type) {
'form_fee' => 'رسوم استمارة',
'membership_fee' => 'قيمة العضوية',
'addition_fee' => 'رسوم إضافة',
'annual_subscription' => 'اشتراك سنوي',
'development_fee' => 'رسوم تنمية',
'down_payment' => 'مقدم تقسيط',
'installment' => 'قسط شهري',
'fine' => 'غرامة',
'separation_fee' => 'رسوم فصل',
'divorce_fee' => 'رسوم طلاق',
'death_fee' => 'رسوم نقل وفاة',
'waiver_fee' => 'رسوم تنازل',
'carnet_replacement' => 'بدل فاقد كارنيه',
'seasonal_fee' => 'رسوم عضوية موسمية',
'sports_conversion' => 'رسوم تحويل رياضي',
'inventory_sale' => 'مبيعات مخزون',
default => $type,
};
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Accounting\Models\BankReconciliation;
/**
* Bank reconciliation logic — matches book entries against bank statement.
*/
final class BankReconciliationService
{
/**
* Create a new reconciliation session.
*/
public static function create(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$bankAccountId = (int) ($data['bank_account_id'] ?? 0);
if ($bankAccountId <= 0) {
return ['success' => false, 'error' => 'الحساب البنكي غير محدد'];
}
// Get book balance from GL
$bankAccount = $db->selectOne("SELECT * FROM bank_accounts WHERE id = ? AND is_archived = 0", [$bankAccountId]);
if (!$bankAccount) {
return ['success' => false, 'error' => 'الحساب البنكي غير موجود'];
}
$bookBalance = $bankAccount['current_balance'] ?? '0.00';
$statementBalance = $data['statement_balance'] ?? '0.00';
$difference = bcsub((string) $statementBalance, (string) $bookBalance, 2);
$db->beginTransaction();
try {
$id = $db->insert('bank_reconciliations', [
'bank_account_id' => $bankAccountId,
'reconciliation_date' => $data['reconciliation_date'] ?? date('Y-m-d'),
'statement_date' => $data['statement_date'] ?? date('Y-m-d'),
'statement_balance' => $statementBalance,
'book_balance' => $bookBalance,
'adjusted_balance' => $bookBalance,
'difference' => $difference,
'status' => 'draft',
'notes' => $data['notes'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
$db->commit();
return ['success' => true, 'reconciliation_id' => $id];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إنشاء المطابقة: ' . $e->getMessage()];
}
}
/**
* Add a reconciliation item (outstanding check, deposit in transit, etc.)
*/
public static function addItem(int $reconciliationId, array $data): array
{
$db = App::getInstance()->db();
$recon = $db->selectOne("SELECT * FROM bank_reconciliations WHERE id = ? AND is_archived = 0", [$reconciliationId]);
if (!$recon) {
return ['success' => false, 'error' => 'المطابقة غير موجودة'];
}
if ($recon['status'] === 'completed' || $recon['status'] === 'approved') {
return ['success' => false, 'error' => 'المطابقة مكتملة — لا يمكن إضافة بنود'];
}
$db->beginTransaction();
try {
$db->insert('bank_reconciliation_items', [
'reconciliation_id' => $reconciliationId,
'item_type' => $data['item_type'],
'description_ar' => $data['description_ar'],
'description_en' => $data['description_en'] ?? null,
'amount' => $data['amount'],
'transaction_date' => $data['transaction_date'] ?? null,
'reference_number' => $data['reference_number'] ?? null,
'is_cleared' => 0,
'created_at' => date('Y-m-d H:i:s'),
]);
// Recalculate adjusted balance
self::recalculate($reconciliationId);
$db->commit();
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إضافة البند: ' . $e->getMessage()];
}
}
/**
* Recalculate the adjusted balance based on items.
*/
public static function recalculate(int $reconciliationId): void
{
$db = App::getInstance()->db();
$recon = $db->selectOne("SELECT * FROM bank_reconciliations WHERE id = ?", [$reconciliationId]);
if (!$recon) {
return;
}
$items = $db->select(
"SELECT * FROM bank_reconciliation_items WHERE reconciliation_id = ? AND is_cleared = 0",
[$reconciliationId]
);
$adjustedBalance = (string) $recon['book_balance'];
foreach ($items as $item) {
$amount = (string) $item['amount'];
switch ($item['item_type']) {
case 'bank_charge':
// Bank deducted but not in books yet — reduce book balance
$adjustedBalance = bcsub($adjustedBalance, $amount, 2);
break;
case 'bank_interest':
// Bank added but not in books yet — increase book balance
$adjustedBalance = bcadd($adjustedBalance, $amount, 2);
break;
case 'outstanding_check':
// Issued check not yet cleared at bank — add back to statement
break;
case 'deposit_in_transit':
// Deposited but not yet in bank — add to statement
break;
case 'book_error':
// Adjust for book errors
$adjustedBalance = bcadd($adjustedBalance, $amount, 2);
break;
case 'bank_error':
// Bank error — no book adjustment
break;
}
}
$difference = bcsub((string) $recon['statement_balance'], $adjustedBalance, 2);
$db->update('bank_reconciliations', [
'adjusted_balance' => $adjustedBalance,
'difference' => $difference,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$reconciliationId]);
}
/**
* Complete reconciliation — creates adjusting journal entries for bank charges/interest.
*/
public static function complete(int $reconciliationId): array
{
$db = App::getInstance()->db();
$recon = $db->selectOne("SELECT * FROM bank_reconciliations WHERE id = ? AND is_archived = 0", [$reconciliationId]);
if (!$recon) {
return ['success' => false, 'error' => 'المطابقة غير موجودة'];
}
if (bccomp((string) $recon['difference'], '0.00', 2) !== 0) {
return ['success' => false, 'error' => 'يوجد فرق ' . $recon['difference'] . ' — يجب تسوية جميع الفروقات أولاً'];
}
// Get items that need adjusting entries (bank charges, bank interest)
$adjustItems = $db->select(
"SELECT * FROM bank_reconciliation_items
WHERE reconciliation_id = ? AND item_type IN ('bank_charge','bank_interest') AND is_cleared = 0",
[$reconciliationId]
);
$bankAccount = $db->selectOne("SELECT * FROM bank_accounts WHERE id = ?", [(int) $recon['bank_account_id']]);
$glAccountId = $bankAccount ? (int) $bankAccount['gl_account_id'] : null;
// Create adjusting entries for bank charges/interest
foreach ($adjustItems as $item) {
if ($glAccountId) {
$lines = [];
if ($item['item_type'] === 'bank_charge') {
// Dr. Bank Charges Expense, Cr. Bank
$bankChargesAccount = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '5201' AND is_archived = 0"
);
if ($bankChargesAccount) {
$lines = [
['account_id' => (int) $bankChargesAccount['id'], 'debit' => $item['amount'], 'credit' => '0.00',
'description_ar' => 'مصاريف بنكية — ' . $item['description_ar']],
['account_id' => $glAccountId, 'debit' => '0.00', 'credit' => $item['amount'],
'description_ar' => 'مصاريف بنكية — ' . $item['description_ar']],
];
}
} elseif ($item['item_type'] === 'bank_interest') {
// Dr. Bank, Cr. Interest Income
$interestAccount = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '4301' AND is_archived = 0"
);
if ($interestAccount) {
$lines = [
['account_id' => $glAccountId, 'debit' => $item['amount'], 'credit' => '0.00',
'description_ar' => 'فوائد بنكية — ' . $item['description_ar']],
['account_id' => (int) $interestAccount['id'], 'debit' => '0.00', 'credit' => $item['amount'],
'description_ar' => 'فوائد بنكية — ' . $item['description_ar']],
];
}
}
if (!empty($lines)) {
$result = JournalService::createEntry([
'entry_date' => $recon['reconciliation_date'],
'description_ar' => 'تسوية مطابقة بنكية — ' . $item['description_ar'],
'reference_type' => 'bank_reconciliation',
'reference_id' => $reconciliationId,
'source_module' => 'accounting',
'is_auto_generated' => 1,
], $lines, true);
if ($result['success']) {
$db->update('bank_reconciliation_items', [
'journal_entry_id' => $result['journal_entry_id'],
'is_cleared' => 1,
'cleared_date' => date('Y-m-d'),
], '`id` = ?', [(int) $item['id']]);
}
}
}
}
$db->update('bank_reconciliations', [
'status' => 'completed',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$reconciliationId]);
return ['success' => true];
}
/**
* Get unreconciled bank transactions for a bank account.
*/
public static function getUnreconciledTransactions(int $bankAccountId, string $asOfDate): array
{
$db = App::getInstance()->db();
$bankAccount = $db->selectOne("SELECT gl_account_id FROM bank_accounts WHERE id = ?", [$bankAccountId]);
if (!$bankAccount || !$bankAccount['gl_account_id']) {
return [];
}
return $db->select(
"SELECT jel.*, je.entry_number, je.entry_date, je.description_ar as entry_description,
je.reference_type, je.reference_number
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_id = ?
AND je.status = 'posted'
AND je.entry_date <= ?
AND je.is_archived = 0
ORDER BY je.entry_date DESC, je.id DESC
LIMIT 200",
[(int) $bankAccount['gl_account_id'], $asOfDate]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* Generates income statement (profit & loss) and balance sheet
* from posted journal entries.
*/
final class FinancialReportService
{
/**
* Income Statement (قائمة الدخل)
* Revenue - Expenses = Net Income
*/
public static function getIncomeStatement(
string $dateFrom,
string $dateTo,
?int $costCenterId = null,
?int $branchId = null
): array {
$db = App::getInstance()->db();
$extraWhere = '';
$params = [$dateFrom, $dateTo];
if ($costCenterId !== null) {
$extraWhere .= ' AND jel.cost_center_id = ?';
$params[] = $costCenterId;
}
if ($branchId !== null) {
$extraWhere .= ' AND jel.branch_id = ?';
$params[] = $branchId;
}
// Revenue accounts (credit-nature)
$revenue = $db->select(
"SELECT coa.id, coa.account_code, coa.name_ar, coa.name_en,
COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as balance
FROM chart_of_accounts coa
LEFT JOIN journal_entry_lines jel ON jel.account_id = coa.id
AND jel.journal_entry_id IN (
SELECT id FROM journal_entries
WHERE status = 'posted' AND entry_date >= ? AND entry_date <= ? AND is_archived = 0
)
{$extraWhere}
WHERE coa.account_type = 'revenue' AND coa.is_archived = 0 AND coa.is_header = 0
GROUP BY coa.id, coa.account_code, coa.name_ar, coa.name_en
HAVING balance != 0
ORDER BY coa.account_code ASC",
$params
);
// Expense accounts (debit-nature)
$expenses = $db->select(
"SELECT coa.id, coa.account_code, coa.name_ar, coa.name_en,
COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
FROM chart_of_accounts coa
LEFT JOIN journal_entry_lines jel ON jel.account_id = coa.id
AND jel.journal_entry_id IN (
SELECT id FROM journal_entries
WHERE status = 'posted' AND entry_date >= ? AND entry_date <= ? AND is_archived = 0
)
{$extraWhere}
WHERE coa.account_type = 'expense' AND coa.is_archived = 0 AND coa.is_header = 0
GROUP BY coa.id, coa.account_code, coa.name_ar, coa.name_en
HAVING balance != 0
ORDER BY coa.account_code ASC",
$params
);
$totalRevenue = '0.00';
foreach ($revenue as $r) {
$totalRevenue = bcadd($totalRevenue, (string) $r['balance'], 2);
}
$totalExpenses = '0.00';
foreach ($expenses as $e) {
$totalExpenses = bcadd($totalExpenses, (string) $e['balance'], 2);
}
$netIncome = bcsub($totalRevenue, $totalExpenses, 2);
return [
'revenue' => $revenue,
'expenses' => $expenses,
'total_revenue' => $totalRevenue,
'total_expenses' => $totalExpenses,
'net_income' => $netIncome,
'date_from' => $dateFrom,
'date_to' => $dateTo,
];
}
/**
* Balance Sheet (الميزانية العمومية)
* Assets = Liabilities + Equity
*/
public static function getBalanceSheet(
string $asOfDate,
?int $costCenterId = null,
?int $branchId = null
): array {
$db = App::getInstance()->db();
$extraWhere = '';
$params = [$asOfDate];
if ($costCenterId !== null) {
$extraWhere .= ' AND jel.cost_center_id = ?';
$params[] = $costCenterId;
}
if ($branchId !== null) {
$extraWhere .= ' AND jel.branch_id = ?';
$params[] = $branchId;
}
// Build balance for each account type up to asOfDate
$allAccounts = $db->select(
"SELECT coa.id, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.opening_balance,
COALESCE(SUM(jel.debit), 0) as total_debit,
COALESCE(SUM(jel.credit), 0) as total_credit
FROM chart_of_accounts coa
LEFT JOIN journal_entry_lines jel ON jel.account_id = coa.id
AND jel.journal_entry_id IN (
SELECT id FROM journal_entries
WHERE status = 'posted' AND entry_date <= ? AND is_archived = 0
)
{$extraWhere}
WHERE coa.is_archived = 0 AND coa.is_header = 0
AND coa.account_type IN ('asset','liability','equity')
GROUP BY coa.id, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.opening_balance
ORDER BY coa.account_code ASC",
$params
);
$assets = [];
$liabilities = [];
$equity = [];
$totalAssets = '0.00';
$totalLiabilities = '0.00';
$totalEquity = '0.00';
foreach ($allAccounts as $acc) {
$opening = (string) $acc['opening_balance'];
if ($acc['account_nature'] === 'debit') {
$balance = bcadd($opening, bcsub((string) $acc['total_debit'], (string) $acc['total_credit'], 2), 2);
} else {
$balance = bcadd($opening, bcsub((string) $acc['total_credit'], (string) $acc['total_debit'], 2), 2);
}
if (bccomp($balance, '0.00', 2) === 0) {
continue;
}
$acc['balance'] = $balance;
switch ($acc['account_type']) {
case 'asset':
$assets[] = $acc;
$totalAssets = bcadd($totalAssets, $balance, 2);
break;
case 'liability':
$liabilities[] = $acc;
$totalLiabilities = bcadd($totalLiabilities, $balance, 2);
break;
case 'equity':
$equity[] = $acc;
$totalEquity = bcadd($totalEquity, $balance, 2);
break;
}
}
// Calculate current-period net income and add to equity
$fiscalYearStart = self::getFiscalYearStart($asOfDate);
$incomeStatement = self::getIncomeStatement($fiscalYearStart, $asOfDate, $costCenterId, $branchId);
$netIncome = $incomeStatement['net_income'];
if (bccomp($netIncome, '0.00', 2) !== 0) {
$equity[] = [
'account_code' => '---',
'name_ar' => 'صافي ربح/خسارة الفترة',
'name_en' => 'Current Period Net Income',
'balance' => $netIncome,
];
$totalEquity = bcadd($totalEquity, $netIncome, 2);
}
$totalLiabilitiesAndEquity = bcadd($totalLiabilities, $totalEquity, 2);
return [
'assets' => $assets,
'liabilities' => $liabilities,
'equity' => $equity,
'total_assets' => $totalAssets,
'total_liabilities' => $totalLiabilities,
'total_equity' => $totalEquity,
'total_liabilities_and_equity' => $totalLiabilitiesAndEquity,
'is_balanced' => bccomp($totalAssets, $totalLiabilitiesAndEquity, 2) === 0,
'as_of_date' => $asOfDate,
];
}
/**
* Multi-branch consolidation — aggregate all branches into one balance sheet.
*/
public static function getConsolidatedBalanceSheet(string $asOfDate): array
{
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar, name_en FROM branches WHERE is_archived = 0 ORDER BY id");
$branchSheets = [];
foreach ($branches as $branch) {
$branchSheets[] = [
'branch' => $branch,
'balance_sheet' => self::getBalanceSheet($asOfDate, null, (int) $branch['id']),
];
}
// Consolidated (all branches)
$consolidated = self::getBalanceSheet($asOfDate);
return [
'consolidated' => $consolidated,
'branch_sheets' => $branchSheets,
'as_of_date' => $asOfDate,
];
}
private static function getFiscalYearStart(string $date): string
{
$config = App::getInstance()->config('app');
$startMonth = (int) ($config['financial_year_start_month'] ?? 7);
$startDay = (int) ($config['financial_year_start_day'] ?? 1);
$year = (int) date('Y', strtotime($date));
$month = (int) date('m', strtotime($date));
if ($month < $startMonth) {
$year--;
}
return sprintf('%04d-%02d-%02d', $year, $startMonth, $startDay);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Models\JournalEntry;
use App\Modules\Accounting\Models\PeriodClosing;
/**
* Core journal entry service — all journal entries (manual + auto) pass through here.
* Validates double-entry integrity, fiscal year boundaries, and period locks.
*/
final class JournalService
{
/**
* Create a journal entry with lines.
*
* @param array $header [entry_date, description_ar, ?description_en, ?reference_type, ?reference_id,
* ?reference_number, ?source_module, ?branch_id, ?cost_center_id, ?is_auto_generated, ?notes]
* @param array $lines Array of [account_id, debit, credit, ?description_ar, ?description_en,
* ?cost_center_id, ?branch_id, ?member_id, ?employee_id, ?supplier_id, ?reference_type, ?reference_id]
* @param bool $autoPost Post immediately (true for auto-generated entries)
*
* @return array ['success' => bool, 'journal_entry_id' => int, 'entry_number' => string] or ['success' => false, 'error' => string]
*/
public static function createEntry(array $header, array $lines, bool $autoPost = false): array
{
$db = App::getInstance()->db();
// ── Validate ───────────────────────────────────────────
if (empty($lines) || count($lines) < 2) {
return ['success' => false, 'error' => 'قيد اليومية يجب أن يحتوي على سطرين على الأقل'];
}
$entryDate = $header['entry_date'] ?? date('Y-m-d');
// Fiscal year check
$fiscalYear = FiscalYear::findByDate($entryDate);
if (!$fiscalYear) {
return ['success' => false, 'error' => 'لا توجد سنة مالية مفتوحة لتاريخ ' . $entryDate];
}
if (!$fiscalYear->isOpen()) {
return ['success' => false, 'error' => 'السنة المالية مغلقة — لا يمكن إضافة قيود'];
}
// Period closed check
$period = substr($entryDate, 0, 7); // YYYY-MM
if (PeriodClosing::isPeriodClosed((int) $fiscalYear->id, $period)) {
return ['success' => false, 'error' => 'الفترة ' . $period . ' مغلقة — لا يمكن إضافة قيود'];
}
// Calculate totals and validate balance
$totalDebit = '0.00';
$totalCredit = '0.00';
foreach ($lines as $line) {
$debit = $line['debit'] ?? '0.00';
$credit = $line['credit'] ?? '0.00';
if (bccomp((string) $debit, '0.00', 2) > 0 && bccomp((string) $credit, '0.00', 2) > 0) {
return ['success' => false, 'error' => 'لا يمكن أن يحتوي سطر واحد على مدين ودائن معاً'];
}
if (bccomp((string) $debit, '0.00', 2) === 0 && bccomp((string) $credit, '0.00', 2) === 0) {
return ['success' => false, 'error' => 'كل سطر يجب أن يحتوي على قيمة مدينة أو دائنة'];
}
$totalDebit = bcadd($totalDebit, (string) $debit, 2);
$totalCredit = bcadd($totalCredit, (string) $credit, 2);
}
if (bccomp($totalDebit, $totalCredit, 2) !== 0) {
return ['success' => false, 'error' => 'مجموع المدين (' . $totalDebit . ') لا يساوي مجموع الدائن (' . $totalCredit . ')'];
}
if (bccomp($totalDebit, '0.00', 2) <= 0) {
return ['success' => false, 'error' => 'مجموع القيد يجب أن يكون أكبر من صفر'];
}
// Validate accounts exist and are postable
$accountIds = array_unique(array_column($lines, 'account_id'));
$placeholders = implode(',', array_fill(0, count($accountIds), '?'));
$accounts = $db->select(
"SELECT id, is_header, is_active FROM chart_of_accounts WHERE id IN ({$placeholders})",
array_values($accountIds)
);
$accountMap = [];
foreach ($accounts as $acc) {
$accountMap[(int) $acc['id']] = $acc;
}
foreach ($accountIds as $accId) {
if (!isset($accountMap[(int) $accId])) {
return ['success' => false, 'error' => 'الحساب رقم ' . $accId . ' غير موجود'];
}
if ((int) $accountMap[(int) $accId]['is_header'] === 1) {
return ['success' => false, 'error' => 'لا يمكن الترحيل إلى حساب رئيسي (header)'];
}
if ((int) $accountMap[(int) $accId]['is_active'] === 0) {
return ['success' => false, 'error' => 'الحساب غير نشط'];
}
}
// ── Create ─────────────────────────────────────────────
$db->beginTransaction();
try {
$entryNumber = JournalEntry::generateEntryNumber();
$entryId = $db->insert('journal_entries', [
'entry_number' => $entryNumber,
'fiscal_year_id' => $fiscalYear->id,
'entry_date' => $entryDate,
'reference_type' => $header['reference_type'] ?? null,
'reference_id' => $header['reference_id'] ?? null,
'reference_number' => $header['reference_number'] ?? null,
'description_ar' => $header['description_ar'],
'description_en' => $header['description_en'] ?? null,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'currency' => $header['currency'] ?? 'EGP',
'status' => $autoPost ? 'posted' : 'draft',
'posted_at' => $autoPost ? date('Y-m-d H:i:s') : null,
'posted_by' => $autoPost ? self::currentEmployeeId() : null,
'is_auto_generated' => (int) ($header['is_auto_generated'] ?? 0),
'source_module' => $header['source_module'] ?? null,
'branch_id' => $header['branch_id'] ?? null,
'cost_center_id' => $header['cost_center_id'] ?? null,
'notes' => $header['notes'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => self::currentEmployeeId(),
]);
foreach ($lines as $i => $line) {
$db->insert('journal_entry_lines', [
'journal_entry_id' => $entryId,
'line_number' => $i + 1,
'account_id' => (int) $line['account_id'],
'description_ar' => $line['description_ar'] ?? null,
'description_en' => $line['description_en'] ?? null,
'debit' => $line['debit'] ?? '0.00',
'credit' => $line['credit'] ?? '0.00',
'currency' => $line['currency'] ?? 'EGP',
'cost_center_id' => $line['cost_center_id'] ?? $header['cost_center_id'] ?? null,
'branch_id' => $line['branch_id'] ?? $header['branch_id'] ?? null,
'member_id' => $line['member_id'] ?? null,
'employee_id' => $line['employee_id'] ?? null,
'supplier_id' => $line['supplier_id'] ?? null,
'reference_type' => $line['reference_type'] ?? null,
'reference_id' => $line['reference_id'] ?? null,
'notes' => $line['notes'] ?? null,
'created_at' => date('Y-m-d H:i:s'),
]);
}
// If auto-post, update account balances immediately
if ($autoPost) {
self::updateAccountBalances($entryId, $lines, $entryDate, (int) $fiscalYear->id);
}
$db->commit();
EventBus::dispatch('accounting.journal_entry.created', [
'journal_entry_id' => $entryId,
'entry_number' => $entryNumber,
'total_debit' => $totalDebit,
'source_module' => $header['source_module'] ?? 'manual',
'auto_posted' => $autoPost,
]);
Logger::info("Journal entry created", [
'entry_id' => $entryId,
'entry_number' => $entryNumber,
'total' => $totalDebit,
'auto' => $autoPost,
]);
return [
'success' => true,
'journal_entry_id' => $entryId,
'entry_number' => $entryNumber,
];
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Journal entry creation failed: " . $e->getMessage());
return ['success' => false, 'error' => 'فشل إنشاء القيد: ' . $e->getMessage()];
}
}
/**
* Post a draft journal entry.
*/
public static function postEntry(int $entryId): array
{
$db = App::getInstance()->db();
$entry = JournalEntry::find($entryId);
if (!$entry) {
return ['success' => false, 'error' => 'القيد غير موجود'];
}
if ($entry->isPosted()) {
return ['success' => false, 'error' => 'القيد مرحّل بالفعل'];
}
if ($entry->isReversed()) {
return ['success' => false, 'error' => 'القيد ملغي — لا يمكن ترحيله'];
}
// Re-validate period is open
$period = substr($entry->entry_date, 0, 7);
if (PeriodClosing::isPeriodClosed((int) $entry->fiscal_year_id, $period)) {
return ['success' => false, 'error' => 'الفترة مغلقة — لا يمكن ترحيل القيد'];
}
$db->beginTransaction();
try {
$db->update('journal_entries', [
'status' => 'posted',
'posted_at' => date('Y-m-d H:i:s'),
'posted_by' => self::currentEmployeeId(),
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => self::currentEmployeeId(),
], '`id` = ?', [$entryId]);
$lines = $db->select(
"SELECT * FROM journal_entry_lines WHERE journal_entry_id = ? ORDER BY line_number",
[$entryId]
);
self::updateAccountBalances($entryId, $lines, $entry->entry_date, (int) $entry->fiscal_year_id);
$db->commit();
EventBus::dispatch('accounting.journal_entry.posted', [
'journal_entry_id' => $entryId,
'entry_number' => $entry->entry_number,
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل ترحيل القيد: ' . $e->getMessage()];
}
}
/**
* Reverse a posted journal entry by creating a mirror entry.
*/
public static function reverseEntry(int $entryId, string $reason, ?string $reversalDate = null): array
{
$db = App::getInstance()->db();
$entry = JournalEntry::find($entryId);
if (!$entry) {
return ['success' => false, 'error' => 'القيد غير موجود'];
}
if (!$entry->isPosted()) {
return ['success' => false, 'error' => 'لا يمكن عكس قيد غير مرحّل'];
}
if ($entry->reversal_of_id !== null) {
return ['success' => false, 'error' => 'القيد معكوس بالفعل'];
}
$revDate = $reversalDate ?? date('Y-m-d');
$lines = $db->select(
"SELECT * FROM journal_entry_lines WHERE journal_entry_id = ? ORDER BY line_number",
[$entryId]
);
// Create reversed lines (swap debit/credit)
$reversedLines = [];
foreach ($lines as $line) {
$reversedLines[] = [
'account_id' => $line['account_id'],
'debit' => $line['credit'],
'credit' => $line['debit'],
'description_ar' => 'عكس: ' . ($line['description_ar'] ?? ''),
'description_en' => $line['description_en'] ? 'Reversal: ' . $line['description_en'] : null,
'cost_center_id' => $line['cost_center_id'],
'branch_id' => $line['branch_id'],
'member_id' => $line['member_id'],
'employee_id' => $line['employee_id'],
'supplier_id' => $line['supplier_id'],
];
}
$result = self::createEntry([
'entry_date' => $revDate,
'description_ar' => 'عكس قيد رقم ' . $entry->entry_number . ' — ' . $reason,
'description_en' => 'Reversal of ' . $entry->entry_number . ' — ' . $reason,
'reference_type' => 'reversal',
'reference_id' => $entryId,
'reference_number' => $entry->entry_number,
'source_module' => $entry->source_module,
'branch_id' => $entry->branch_id,
'cost_center_id' => $entry->cost_center_id,
'is_auto_generated' => 1,
'notes' => $reason,
], $reversedLines, true);
if (!$result['success']) {
return $result;
}
// Mark original as reversed and link
$db->update('journal_entries', [
'status' => 'reversed',
'reversal_of_id' => $result['journal_entry_id'],
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => self::currentEmployeeId(),
], '`id` = ?', [$entryId]);
// Link reversal entry to original
$db->update('journal_entries', [
'reversed_entry_id' => $entryId,
], '`id` = ?', [$result['journal_entry_id']]);
EventBus::dispatch('accounting.journal_entry.reversed', [
'original_entry_id' => $entryId,
'reversal_entry_id' => $result['journal_entry_id'],
'reason' => $reason,
]);
return [
'success' => true,
'reversal_entry_id' => $result['journal_entry_id'],
'reversal_number' => $result['entry_number'],
];
}
/**
* Update account running balances when an entry is posted.
*/
private static function updateAccountBalances(int $entryId, array $lines, string $entryDate, int $fiscalYearId): void
{
$db = App::getInstance()->db();
$period = substr($entryDate, 0, 7);
foreach ($lines as $line) {
$accountId = (int) $line['account_id'];
$debit = $line['debit'] ?? '0.00';
$credit = $line['credit'] ?? '0.00';
$costCenterId = $line['cost_center_id'] ?? null;
$branchId = $line['branch_id'] ?? null;
// Update chart_of_accounts.current_balance
$account = $db->selectOne("SELECT account_nature, current_balance FROM chart_of_accounts WHERE id = ?", [$accountId]);
if ($account) {
$change = bcsub((string) $debit, (string) $credit, 2);
if ($account['account_nature'] === 'credit') {
$change = bcsub((string) $credit, (string) $debit, 2);
}
$newBalance = bcadd((string) $account['current_balance'], $change, 2);
$db->update('chart_of_accounts', ['current_balance' => $newBalance], '`id` = ?', [$accountId]);
}
// Upsert account_balances for the period
$existing = $db->selectOne(
"SELECT id, period_debit, period_credit FROM account_balances
WHERE account_id = ? AND fiscal_year_id = ? AND period = ?
AND (cost_center_id IS NULL AND ? IS NULL OR cost_center_id = ?)
AND (branch_id IS NULL AND ? IS NULL OR branch_id = ?)",
[$accountId, $fiscalYearId, $period, $costCenterId, $costCenterId, $branchId, $branchId]
);
if ($existing) {
$newDebit = bcadd((string) $existing['period_debit'], (string) $debit, 2);
$newCredit = bcadd((string) $existing['period_credit'], (string) $credit, 2);
$db->update('account_balances', [
'period_debit' => $newDebit,
'period_credit' => $newCredit,
'closing_debit' => bcadd((string) ($existing['opening_debit'] ?? '0.00'), $newDebit, 2),
'closing_credit' => bcadd((string) ($existing['opening_credit'] ?? '0.00'), $newCredit, 2),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $existing['id']]);
} else {
$db->insert('account_balances', [
'account_id' => $accountId,
'fiscal_year_id' => $fiscalYearId,
'period' => $period,
'opening_debit' => '0.00',
'opening_credit' => '0.00',
'period_debit' => $debit,
'period_credit' => $credit,
'closing_debit' => $debit,
'closing_credit' => $credit,
'cost_center_id' => $costCenterId,
'branch_id' => $branchId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
}
private static function currentEmployeeId(): ?int
{
$emp = App::getInstance()->currentEmployee();
if ($emp) {
return (int) ($emp->id ?? $emp['id'] ?? null);
}
return null;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
/**
* General Ledger and Trial Balance queries.
* Works from posted journal entries — never touches draft entries.
*/
final class LedgerService
{
/**
* Get general ledger for a specific account within a date range.
*
* @return array ['account' => [...], 'opening_balance' => string, 'entries' => [...], 'closing_balance' => string]
*/
public static function getAccountLedger(
int $accountId,
string $dateFrom,
string $dateTo,
?int $costCenterId = null,
?int $branchId = null
): array {
$db = App::getInstance()->db();
$account = $db->selectOne("SELECT * FROM chart_of_accounts WHERE id = ?", [$accountId]);
if (!$account) {
return ['account' => null, 'opening_balance' => '0.00', 'entries' => [], 'closing_balance' => '0.00'];
}
// Opening balance = all posted entries before dateFrom
$openingWhere = "je.status = 'posted' AND je.entry_date < ? AND je.is_archived = 0";
$openingParams = [$dateFrom];
$extraJoin = '';
if ($costCenterId !== null) {
$openingWhere .= ' AND jel.cost_center_id = ?';
$openingParams[] = $costCenterId;
}
if ($branchId !== null) {
$openingWhere .= ' AND jel.branch_id = ?';
$openingParams[] = $branchId;
}
$openingRow = $db->selectOne(
"SELECT COALESCE(SUM(jel.debit), 0) as total_debit,
COALESCE(SUM(jel.credit), 0) as total_credit
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_id = ? AND {$openingWhere}",
array_merge([$accountId], $openingParams)
);
$openingDebit = $openingRow['total_debit'] ?? '0.00';
$openingCredit = $openingRow['total_credit'] ?? '0.00';
// Opening balance adjusted for account nature
if ($account['account_nature'] === 'debit') {
$openingBalance = bcsub($openingDebit, $openingCredit, 2);
$openingBalance = bcadd($openingBalance, (string) $account['opening_balance'], 2);
} else {
$openingBalance = bcsub($openingCredit, $openingDebit, 2);
$openingBalance = bcadd($openingBalance, (string) $account['opening_balance'], 2);
}
// Ledger entries within the period
$periodWhere = "je.status = 'posted' AND je.entry_date >= ? AND je.entry_date <= ? AND je.is_archived = 0";
$periodParams = [$dateFrom, $dateTo];
if ($costCenterId !== null) {
$periodWhere .= ' AND jel.cost_center_id = ?';
$periodParams[] = $costCenterId;
}
if ($branchId !== null) {
$periodWhere .= ' AND jel.branch_id = ?';
$periodParams[] = $branchId;
}
$entries = $db->select(
"SELECT jel.*, je.entry_number, je.entry_date, je.description_ar as entry_description,
je.reference_type, je.reference_number, je.source_module
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_id = ? AND {$periodWhere}
ORDER BY je.entry_date ASC, je.id ASC, jel.line_number ASC",
array_merge([$accountId], $periodParams)
);
// Calculate running balance
$running = $openingBalance;
foreach ($entries as &$entry) {
if ($account['account_nature'] === 'debit') {
$running = bcadd($running, bcsub((string) $entry['debit'], (string) $entry['credit'], 2), 2);
} else {
$running = bcadd($running, bcsub((string) $entry['credit'], (string) $entry['debit'], 2), 2);
}
$entry['running_balance'] = $running;
}
unset($entry);
return [
'account' => $account,
'opening_balance' => $openingBalance,
'entries' => $entries,
'closing_balance' => $running,
];
}
/**
* Generate trial balance for a given date range or period.
*
* @return array ['accounts' => [...], 'total_debit' => string, 'total_credit' => string, 'is_balanced' => bool]
*/
public static function getTrialBalance(
string $dateFrom,
string $dateTo,
?int $fiscalYearId = null,
?int $costCenterId = null,
?int $branchId = null,
bool $includeZeroBalances = false
): array {
$db = App::getInstance()->db();
$extraWhere = '';
$params = [$dateFrom, $dateTo];
if ($costCenterId !== null) {
$extraWhere .= ' AND jel.cost_center_id = ?';
$params[] = $costCenterId;
}
if ($branchId !== null) {
$extraWhere .= ' AND jel.branch_id = ?';
$params[] = $branchId;
}
// Get all accounts with their period movement
$accounts = $db->select(
"SELECT coa.id, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.level, coa.is_header,
coa.opening_balance,
COALESCE(SUM(jel.debit), 0) as total_debit,
COALESCE(SUM(jel.credit), 0) as total_credit
FROM chart_of_accounts coa
LEFT JOIN journal_entry_lines jel ON jel.account_id = coa.id
AND jel.journal_entry_id IN (
SELECT id FROM journal_entries
WHERE status = 'posted' AND entry_date >= ? AND entry_date <= ? AND is_archived = 0
)
{$extraWhere}
WHERE coa.is_archived = 0 AND coa.is_header = 0
GROUP BY coa.id, coa.account_code, coa.name_ar, coa.name_en,
coa.account_type, coa.account_nature, coa.level, coa.is_header,
coa.opening_balance
ORDER BY coa.account_code ASC",
$params
);
$totalDebit = '0.00';
$totalCredit = '0.00';
$filtered = [];
foreach ($accounts as &$acc) {
$debitMovement = (string) $acc['total_debit'];
$creditMovement = (string) $acc['total_credit'];
$opening = (string) $acc['opening_balance'];
// Calculate net balance
if ($acc['account_nature'] === 'debit') {
$balance = bcadd($opening, bcsub($debitMovement, $creditMovement, 2), 2);
} else {
$balance = bcadd($opening, bcsub($creditMovement, $debitMovement, 2), 2);
}
// Determine trial balance columns
if (bccomp($balance, '0.00', 2) >= 0) {
if ($acc['account_nature'] === 'debit') {
$acc['tb_debit'] = $balance;
$acc['tb_credit'] = '0.00';
} else {
$acc['tb_debit'] = '0.00';
$acc['tb_credit'] = $balance;
}
} else {
// Negative balance — show on opposite side
$absBalance = bcmul($balance, '-1', 2);
if ($acc['account_nature'] === 'debit') {
$acc['tb_debit'] = '0.00';
$acc['tb_credit'] = $absBalance;
} else {
$acc['tb_debit'] = $absBalance;
$acc['tb_credit'] = '0.00';
}
}
$acc['balance'] = $balance;
if (!$includeZeroBalances && bccomp($balance, '0.00', 2) === 0
&& bccomp($debitMovement, '0.00', 2) === 0
&& bccomp($creditMovement, '0.00', 2) === 0) {
continue;
}
$totalDebit = bcadd($totalDebit, $acc['tb_debit'], 2);
$totalCredit = bcadd($totalCredit, $acc['tb_credit'], 2);
$filtered[] = $acc;
}
unset($acc);
return [
'accounts' => $filtered,
'total_debit' => $totalDebit,
'total_credit' => $totalCredit,
'is_balanced' => bccomp($totalDebit, $totalCredit, 2) === 0,
];
}
/**
* Get account statement (similar to bank statement style) for a member.
*/
public static function getMemberStatement(int $memberId, string $dateFrom, string $dateTo): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT jel.*, je.entry_number, je.entry_date, je.description_ar as entry_description,
je.reference_type, je.reference_number,
coa.account_code, coa.name_ar as account_name
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
JOIN chart_of_accounts coa ON coa.id = jel.account_id
WHERE jel.member_id = ?
AND je.status = 'posted'
AND je.entry_date >= ? AND je.entry_date <= ?
AND je.is_archived = 0
ORDER BY je.entry_date ASC, je.id ASC",
[$memberId, $dateFrom, $dateTo]
);
}
/**
* Get all journal entry lines for a specific account in a period.
*/
public static function getAccountMovement(int $accountId, string $period): array
{
$db = App::getInstance()->db();
$dateFrom = $period . '-01';
$dateTo = date('Y-m-t', strtotime($dateFrom));
return $db->select(
"SELECT jel.*, je.entry_number, je.entry_date, je.description_ar as entry_description,
je.reference_type, je.reference_number, je.source_module
FROM journal_entry_lines jel
JOIN journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_id = ?
AND je.status = 'posted'
AND je.entry_date >= ? AND je.entry_date <= ?
AND je.is_archived = 0
ORDER BY je.entry_date ASC, je.id ASC",
[$accountId, $dateFrom, $dateTo]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Accounting\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Accounting\Models\FiscalYear;
use App\Modules\Accounting\Models\PeriodClosing;
/**
* Handles monthly and yearly period closing.
* - Monthly: locks the period, no more postings allowed
* - Yearly: closes revenue/expense to retained earnings, carries balances forward
*/
final class PeriodClosingService
{
/**
* Close a monthly period.
*/
public static function closeMonth(int $fiscalYearId, string $period): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$fiscalYear = FiscalYear::find($fiscalYearId);
if (!$fiscalYear || !$fiscalYear->isOpen()) {
return ['success' => false, 'error' => 'السنة المالية غير مفتوحة'];
}
// Check if already closed
if (PeriodClosing::isPeriodClosed($fiscalYearId, $period)) {
return ['success' => false, 'error' => 'الفترة مغلقة بالفعل'];
}
// Check for unposted draft entries in this period
$dateFrom = $period . '-01';
$dateTo = date('Y-m-t', strtotime($dateFrom));
$drafts = $db->selectOne(
"SELECT COUNT(*) as cnt FROM journal_entries
WHERE fiscal_year_id = ? AND entry_date >= ? AND entry_date <= ?
AND status = 'draft' AND is_archived = 0",
[$fiscalYearId, $dateFrom, $dateTo]
);
if ((int) ($drafts['cnt'] ?? 0) > 0) {
return ['success' => false, 'error' => 'يوجد ' . $drafts['cnt'] . ' قيود مسودة غير مرحّلة — يجب ترحيلها أو حذفها أولاً'];
}
// Get period totals
$totals = $db->selectOne(
"SELECT COALESCE(SUM(total_debit), 0) as total_debit,
COALESCE(SUM(total_credit), 0) as total_credit,
COUNT(*) as journal_count
FROM journal_entries
WHERE fiscal_year_id = ? AND entry_date >= ? AND entry_date <= ?
AND status = 'posted' AND is_archived = 0",
[$fiscalYearId, $dateFrom, $dateTo]
);
$db->beginTransaction();
try {
// Create or update period closing record
$existing = PeriodClosing::findByPeriod($fiscalYearId, $period);
if ($existing) {
$db->update('period_closings', [
'status' => 'closed',
'total_debit' => $totals['total_debit'],
'total_credit' => $totals['total_credit'],
'journal_count' => (int) $totals['journal_count'],
'closed_at' => date('Y-m-d H:i:s'),
'closed_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $existing->id]);
} else {
$db->insert('period_closings', [
'fiscal_year_id' => $fiscalYearId,
'period' => $period,
'period_start' => $dateFrom,
'period_end' => $dateTo,
'closing_type' => 'monthly',
'status' => 'closed',
'total_debit' => $totals['total_debit'],
'total_credit' => $totals['total_credit'],
'journal_count' => (int) $totals['journal_count'],
'closed_at' => date('Y-m-d H:i:s'),
'closed_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
}
// Lock journal entries in this period
$db->execute(
"UPDATE journal_entries SET period_closed = 1
WHERE fiscal_year_id = ? AND entry_date >= ? AND entry_date <= ?
AND status = 'posted' AND is_archived = 0",
[$fiscalYearId, $dateFrom, $dateTo]
);
$db->commit();
EventBus::dispatch('accounting.period.closed', [
'fiscal_year_id' => $fiscalYearId,
'period' => $period,
]);
Logger::info("Period closed", ['fiscal_year_id' => $fiscalYearId, 'period' => $period]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إغلاق الفترة: ' . $e->getMessage()];
}
}
/**
* Reopen a closed monthly period (requires special permission).
*/
public static function reopenMonth(int $fiscalYearId, string $period, string $reason): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$pc = PeriodClosing::findByPeriod($fiscalYearId, $period);
if (!$pc || $pc->status !== 'closed') {
return ['success' => false, 'error' => 'الفترة غير مغلقة'];
}
$db->beginTransaction();
try {
$db->update('period_closings', [
'status' => 'reopened',
'reopened_at' => date('Y-m-d H:i:s'),
'reopened_by' => $employee ? (int) $employee->id : null,
'reopen_reason' => $reason,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $pc->id]);
// Unlock journal entries
$dateFrom = $period . '-01';
$dateTo = date('Y-m-t', strtotime($dateFrom));
$db->execute(
"UPDATE journal_entries SET period_closed = 0
WHERE fiscal_year_id = ? AND entry_date >= ? AND entry_date <= ?
AND status = 'posted' AND is_archived = 0",
[$fiscalYearId, $dateFrom, $dateTo]
);
$db->commit();
Logger::info("Period reopened", [
'fiscal_year_id' => $fiscalYearId,
'period' => $period,
'reason' => $reason,
]);
return ['success' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إعادة فتح الفترة: ' . $e->getMessage()];
}
}
/**
* Close fiscal year — creates closing entry moving revenue/expense to retained earnings.
*/
public static function closeFiscalYear(int $fiscalYearId): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$fiscalYear = FiscalYear::find($fiscalYearId);
if (!$fiscalYear) {
return ['success' => false, 'error' => 'السنة المالية غير موجودة'];
}
if (!$fiscalYear->isOpen()) {
return ['success' => false, 'error' => 'السنة المالية مغلقة بالفعل'];
}
// Check all monthly periods are closed
$openPeriods = $db->selectOne(
"SELECT COUNT(*) as cnt FROM period_closings
WHERE fiscal_year_id = ? AND status != 'closed' AND is_archived = 0",
[$fiscalYearId]
);
// Get income statement for the entire year
$incomeStatement = FinancialReportService::getIncomeStatement(
$fiscalYear->start_date,
$fiscalYear->end_date
);
$netIncome = $incomeStatement['net_income'];
// Retained earnings account (3102)
$retainedEarnings = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = '3102' AND is_archived = 0"
);
if (!$retainedEarnings) {
return ['success' => false, 'error' => 'حساب الأرباح المحتجزة (3102) غير موجود'];
}
$db->beginTransaction();
try {
$lines = [];
// Close revenue accounts (debit revenue accounts to zero them out)
foreach ($incomeStatement['revenue'] as $rev) {
if (bccomp((string) $rev['balance'], '0.00', 2) !== 0) {
$lines[] = [
'account_id' => (int) $rev['id'],
'debit' => (string) $rev['balance'],
'credit' => '0.00',
'description_ar' => 'إقفال حساب إيرادات — ' . $rev['name_ar'],
];
}
}
// Close expense accounts (credit expense accounts to zero them out)
foreach ($incomeStatement['expenses'] as $exp) {
if (bccomp((string) $exp['balance'], '0.00', 2) !== 0) {
$lines[] = [
'account_id' => (int) $exp['id'],
'debit' => '0.00',
'credit' => (string) $exp['balance'],
'description_ar' => 'إقفال حساب مصروفات — ' . $exp['name_ar'],
];
}
}
// Net income to retained earnings
if (bccomp($netIncome, '0.00', 2) > 0) {
// Profit: Cr. Retained Earnings
$lines[] = [
'account_id' => (int) $retainedEarnings['id'],
'debit' => '0.00',
'credit' => $netIncome,
'description_ar' => 'صافي ربح السنة المالية — أرباح محتجزة',
];
} elseif (bccomp($netIncome, '0.00', 2) < 0) {
// Loss: Dr. Retained Earnings
$lines[] = [
'account_id' => (int) $retainedEarnings['id'],
'debit' => bcmul($netIncome, '-1', 2),
'credit' => '0.00',
'description_ar' => 'صافي خسارة السنة المالية — أرباح محتجزة',
];
}
if (empty($lines)) {
return ['success' => false, 'error' => 'لا توجد حسابات إيرادات أو مصروفات لإقفالها'];
}
$closingResult = JournalService::createEntry([
'entry_date' => $fiscalYear->end_date,
'description_ar' => 'قيد إقفال السنة المالية ' . $fiscalYear->name_ar,
'description_en' => 'Year-end closing entry — ' . $fiscalYear->name_en,
'reference_type' => 'closing',
'source_module' => 'accounting',
'is_auto_generated' => 1,
], $lines, true);
if (!$closingResult['success']) {
$db->rollBack();
return $closingResult;
}
// Update fiscal year status
$db->update('fiscal_years', [
'status' => 'closed',
'is_current' => 0,
'closed_at' => date('Y-m-d H:i:s'),
'closed_by' => $employee ? (int) $employee->id : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$fiscalYearId]);
// Create year-end period closing record
$endPeriod = substr($fiscalYear->end_date, 0, 7);
$db->insert('period_closings', [
'fiscal_year_id' => $fiscalYearId,
'period' => $endPeriod . '-YE',
'period_start' => $fiscalYear->start_date,
'period_end' => $fiscalYear->end_date,
'closing_type' => 'yearly',
'status' => 'closed',
'closing_entry_id' => $closingResult['journal_entry_id'],
'closed_at' => date('Y-m-d H:i:s'),
'closed_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employee ? (int) $employee->id : null,
]);
$db->commit();
EventBus::dispatch('accounting.fiscal_year.closed', [
'fiscal_year_id' => $fiscalYearId,
'net_income' => $netIncome,
'closing_entry' => $closingResult['journal_entry_id'],
]);
Logger::info("Fiscal year closed", [
'fiscal_year_id' => $fiscalYearId,
'net_income' => $netIncome,
]);
return [
'success' => true,
'net_income' => $netIncome,
'closing_entry_id' => $closingResult['journal_entry_id'],
'closing_entry_num' => $closingResult['entry_number'],
];
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Fiscal year closing failed: " . $e->getMessage());
return ['success' => false, 'error' => 'فشل إقفال السنة المالية: ' . $e->getMessage()];
}
}
/**
* Get period status summary for a fiscal year (calendar view).
*/
public static function getPeriodSummary(int $fiscalYearId): array
{
$db = App::getInstance()->db();
$fiscalYear = FiscalYear::find($fiscalYearId);
if (!$fiscalYear) {
return [];
}
$periods = [];
$start = new \DateTime($fiscalYear->start_date);
$end = new \DateTime($fiscalYear->end_date);
while ($start <= $end) {
$period = $start->format('Y-m');
$periodStart = $start->format('Y-m-01');
$periodEnd = $start->format('Y-m-t');
$pc = PeriodClosing::findByPeriod($fiscalYearId, $period);
$totals = $db->selectOne(
"SELECT COALESCE(SUM(total_debit), 0) as total_debit,
COUNT(*) as journal_count
FROM journal_entries
WHERE fiscal_year_id = ? AND entry_date >= ? AND entry_date <= ?
AND status = 'posted' AND is_archived = 0",
[$fiscalYearId, $periodStart, $periodEnd]
);
$periods[] = [
'period' => $period,
'period_start' => $periodStart,
'period_end' => $periodEnd,
'status' => $pc ? $pc->status : 'open',
'total_debit' => $totals['total_debit'] ?? '0.00',
'journal_count' => (int) ($totals['journal_count'] ?? 0),
'closed_at' => $pc ? $pc->closed_at : null,
];
$start->modify('first day of next month');
}
return $periods;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>حساب بنكي جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إضافة حساب بنكي جديد</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/bank-accounts">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">اسم الحساب (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="account_name_ar" class="form-input" required>
</div>
<div>
<label class="form-label">اسم الحساب (إنجليزي)</label>
<input type="text" name="account_name_en" class="form-input" dir="ltr">
</div>
<div>
<label class="form-label">اسم البنك (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="bank_name_ar" class="form-input" required>
</div>
<div>
<label class="form-label">اسم البنك (إنجليزي)</label>
<input type="text" name="bank_name_en" class="form-input" dir="ltr">
</div>
<div>
<label class="form-label">رقم الحساب <span style="color:#DC2626;">*</span></label>
<input type="text" name="account_number" class="form-input" required dir="ltr">
</div>
<div>
<label class="form-label">IBAN</label>
<input type="text" name="iban" class="form-input" dir="ltr">
</div>
<div>
<label class="form-label">SWIFT Code</label>
<input type="text" name="swift_code" class="form-input" dir="ltr">
</div>
<div>
<label class="form-label">فرع البنك</label>
<input type="text" name="branch_name" class="form-input">
</div>
<div>
<label class="form-label">حساب دفتر الأستاذ</label>
<select name="gl_account_id" class="form-select">
<option value="">— ربط بحساب —</option>
<?php foreach ($gl_accounts as $gl): ?>
<option value="<?= (int)$gl['id'] ?>"><?= e($gl['account_code']) ?><?= e($gl['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">الرصيد الافتتاحي</label>
<input type="number" name="opening_balance" class="form-input" value="0.00" step="0.01" dir="ltr">
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_default" value="1"> حساب افتراضي
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ</button>
<a href="/accounting/bank-accounts" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل حساب بنكي<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">تعديل: <?= e($account['account_name_ar']) ?></h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/bank-accounts/<?= (int)$account['id'] ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">اسم الحساب (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="account_name_ar" class="form-input" value="<?= e($account['account_name_ar']) ?>" required>
</div>
<div>
<label class="form-label">اسم الحساب (إنجليزي)</label>
<input type="text" name="account_name_en" class="form-input" value="<?= e($account['account_name_en'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label">اسم البنك (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="bank_name_ar" class="form-input" value="<?= e($account['bank_name_ar']) ?>" required>
</div>
<div>
<label class="form-label">اسم البنك (إنجليزي)</label>
<input type="text" name="bank_name_en" class="form-input" value="<?= e($account['bank_name_en'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label">IBAN</label>
<input type="text" name="iban" class="form-input" value="<?= e($account['iban'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label">SWIFT Code</label>
<input type="text" name="swift_code" class="form-input" value="<?= e($account['swift_code'] ?? '') ?>" dir="ltr">
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_active" value="1" <?= (int)$account['is_active'] ? 'checked' : '' ?>> نشط
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"><?= e($account['notes'] ?? '') ?></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ</button>
<a href="/accounting/bank-accounts" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الحسابات البنكية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/bank-accounts/create" class="btn btn-primary">+ حساب بنكي جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>اسم الحساب</th>
<th>البنك</th>
<th>رقم الحساب</th>
<th>العملة</th>
<th>الرصيد الحالي</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($accounts as $acc): ?>
<tr>
<td style="font-weight:600;"><?= e($acc['account_name_ar']) ?><?= (int)$acc['is_default'] ? ' <span style="color:#0D7377;font-size:11px;">(افتراضي)</span>' : '' ?></td>
<td><?= e($acc['bank_name_ar']) ?></td>
<td style="direction:ltr;text-align:right;"><?= e($acc['account_number']) ?></td>
<td><?= e($acc['currency']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($acc['current_balance']) ?></td>
<td><span style="color:<?= (int)$acc['is_active'] ? '#059669' : '#DC2626' ?>;font-weight:600;"><?= (int)$acc['is_active'] ? 'نشط' : 'موقف' ?></span></td>
<td><a href="/accounting/bank-accounts/<?= (int)$acc['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($accounts)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا توجد حسابات بنكية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مطابقة بنكية جديدة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إنشاء مطابقة بنكية</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/bank-reconciliation">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الحساب البنكي <span style="color:#DC2626;">*</span></label>
<select name="bank_account_id" class="form-select" required>
<option value="">— اختر —</option>
<?php foreach ($bank_accounts as $ba): ?>
<option value="<?= (int)$ba['id'] ?>"><?= e($ba['account_name_ar']) ?><?= e($ba['bank_name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">رصيد كشف البنك <span style="color:#DC2626;">*</span></label>
<input type="number" name="statement_balance" class="form-input" step="0.01" required dir="ltr">
</div>
<div>
<label class="form-label">تاريخ كشف البنك <span style="color:#DC2626;">*</span></label>
<input type="date" name="statement_date" class="form-input" value="<?= date('Y-m-d') ?>" required>
</div>
<div>
<label class="form-label">تاريخ المطابقة</label>
<input type="date" name="reconciliation_date" class="form-input" value="<?= date('Y-m-d') ?>">
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">إنشاء المطابقة</button>
<a href="/accounting/bank-reconciliation" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المطابقات البنكية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/bank-reconciliation/create" class="btn btn-primary">+ مطابقة جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الحساب البنكي</th>
<th>تاريخ المطابقة</th>
<th>رصيد الكشف</th>
<th>رصيد الدفاتر</th>
<th>الفرق</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($reconciliations as $r): ?>
<?php
$statusColor = match($r['status']) {
'draft' => '#F59E0B',
'in_progress' => '#0284C7',
'completed' => '#059669',
'approved' => '#0D7377',
default => '#6B7280',
};
$statusLabel = match($r['status']) {
'draft' => 'مسودة',
'in_progress' => 'جاري العمل',
'completed' => 'مكتملة',
'approved' => 'معتمدة',
default => $r['status'],
};
?>
<tr>
<td><?= e($r['bank_account_name'] ?? '—') ?></td>
<td><?= e($r['reconciliation_date']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($r['statement_balance']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($r['book_balance']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;color:<?= bccomp((string)$r['difference'], '0.00', 2) === 0 ? '#059669' : '#DC2626' ?>;"><?= money($r['difference']) ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/bank-reconciliation/<?= (int)$r['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($reconciliations)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا توجد مطابقات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مطابقة بنكية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusLabel = match($recon['status']) {
'draft' => 'مسودة',
'in_progress' => 'جاري العمل',
'completed' => 'مكتملة',
'approved' => 'معتمدة',
default => $recon['status'],
};
?>
<!-- Summary -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:20px;">
<h2 style="margin:0 0 15px;">مطابقة: <?= e($bank_account['account_name_ar'] ?? '—') ?></h2>
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">رصيد كشف البنك</div>
<div style="font-size:20px;font-weight:700;direction:ltr;"><?= money($recon['statement_balance']) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">رصيد الدفاتر</div>
<div style="font-size:20px;font-weight:700;direction:ltr;"><?= money($recon['book_balance']) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">الرصيد المعدل</div>
<div style="font-size:20px;font-weight:700;direction:ltr;"><?= money($recon['adjusted_balance']) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;background:<?= bccomp((string)$recon['difference'], '0.00', 2) === 0 ? '#F0FDF4' : '#FEF2F2' ?>;">
<div style="font-size:12px;color:#6B7280;">الفرق</div>
<div style="font-size:20px;font-weight:700;color:<?= bccomp((string)$recon['difference'], '0.00', 2) === 0 ? '#059669' : '#DC2626' ?>;direction:ltr;"><?= money($recon['difference']) ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">الحالة</div>
<div style="font-size:16px;font-weight:700;"><?= $statusLabel ?></div>
</div>
</div>
<?php if ($recon['status'] !== 'completed' && $recon['status'] !== 'approved'): ?>
<div style="margin-top:15px;display:flex;gap:8px;">
<?php if (bccomp((string)$recon['difference'], '0.00', 2) === 0): ?>
<form method="POST" action="/accounting/bank-reconciliation/<?= (int)$recon['id'] ?>/complete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" onclick="return confirm('هل تريد إكمال المطابقة؟')">إكمال المطابقة</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<!-- Reconciliation Items -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<!-- Items List -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">بنود المطابقة</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>النوع</th>
<th>الوصف</th>
<th>المبلغ</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<?php
$typeLabel = match($item['item_type']) {
'outstanding_check' => 'شيك معلق',
'deposit_in_transit' => 'إيداع في الطريق',
'bank_charge' => 'مصاريف بنكية',
'bank_interest' => 'فوائد بنكية',
'book_error' => 'خطأ دفتري',
'bank_error' => 'خطأ بنكي',
default => $item['item_type'],
};
?>
<tr>
<td style="font-size:12px;"><?= $typeLabel ?></td>
<td style="font-size:13px;"><?= e($item['description_ar']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($item['amount']) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($items)): ?>
<tr><td colspan="3" style="text-align:center;color:#6B7280;padding:20px;">لا توجد بنود</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- Add Item Form -->
<?php if ($recon['status'] !== 'completed' && $recon['status'] !== 'approved'): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إضافة بند</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/bank-reconciliation/<?= (int)$recon['id'] ?>/add-item">
<?= csrf_field() ?>
<div style="display:grid;gap:12px;">
<div>
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="item_type" class="form-select" required>
<option value="">— اختر —</option>
<option value="outstanding_check">شيك معلق</option>
<option value="deposit_in_transit">إيداع في الطريق</option>
<option value="bank_charge">مصاريف بنكية</option>
<option value="bank_interest">فوائد بنكية</option>
<option value="book_error">خطأ دفتري</option>
<option value="bank_error">خطأ بنكي</option>
</select>
</div>
<div>
<label class="form-label">الوصف <span style="color:#DC2626;">*</span></label>
<input type="text" name="description_ar" class="form-input" required>
</div>
<div>
<label class="form-label">المبلغ <span style="color:#DC2626;">*</span></label>
<input type="number" name="amount" class="form-input" step="0.01" required dir="ltr">
</div>
<div>
<label class="form-label">رقم المرجع</label>
<input type="text" name="reference_number" class="form-input" dir="ltr">
</div>
<button type="submit" class="btn btn-primary">إضافة</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>حساب جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إضافة حساب جديد لدليل الحسابات</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/chart-of-accounts">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">كود الحساب <span style="color:#DC2626;">*</span></label>
<input type="text" name="account_code" class="form-input" placeholder="مثال: 1101" required dir="ltr">
</div>
<div>
<label class="form-label">الحساب الأب</label>
<select name="parent_id" class="form-select">
<option value="">— بدون (حساب رئيسي) —</option>
<?php foreach ($parents as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e($p['account_code']) ?><?= e($p['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">اسم الحساب (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required>
</div>
<div>
<label class="form-label">اسم الحساب (إنجليزي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_en" class="form-input" required dir="ltr">
</div>
<div>
<label class="form-label">نوع الحساب <span style="color:#DC2626;">*</span></label>
<select name="account_type" class="form-select" required>
<option value="">— اختر —</option>
<option value="asset">أصول</option>
<option value="liability">خصوم</option>
<option value="equity">حقوق ملكية</option>
<option value="revenue">إيرادات</option>
<option value="expense">مصروفات</option>
</select>
</div>
<div>
<label class="form-label">طبيعة الحساب <span style="color:#DC2626;">*</span></label>
<select name="account_nature" class="form-select" required>
<option value="">— اختر —</option>
<option value="debit">مدين</option>
<option value="credit">دائن</option>
</select>
</div>
<div>
<label class="form-label">رصيد افتتاحي</label>
<input type="number" name="opening_balance" class="form-input" value="0.00" step="0.01" dir="ltr">
</div>
<div>
<label class="form-label">مركز التكلفة الافتراضي</label>
<select name="cost_center_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>"><?= e($cc['code']) ?><?= e($cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_header" value="1"> حساب رئيسي (لا يُرحّل إليه)
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">وصف (عربي)</label>
<textarea name="description_ar" class="form-textarea" rows="2"></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ الحساب</button>
<a href="/accounting/chart-of-accounts" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل حساب — <?= e($account['account_code']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">تعديل حساب: <?= e($account['account_code']) ?><?= e($account['name_ar']) ?></h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/chart-of-accounts/<?= (int)$account['id'] ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">كود الحساب</label>
<input type="text" class="form-input" value="<?= e($account['account_code']) ?>" disabled dir="ltr" style="background:#F3F4F6;">
</div>
<div>
<label class="form-label">الحساب الأب</label>
<select name="parent_id" class="form-select" disabled>
<option value="">— بدون —</option>
<?php foreach ($parents as $p): ?>
<option value="<?= (int)$p['id'] ?>" <?= (int)$p['id'] === (int)($account['parent_id'] ?? 0) ? 'selected' : '' ?>><?= e($p['account_code']) ?><?= e($p['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">اسم الحساب (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" value="<?= e($account['name_ar']) ?>" required>
</div>
<div>
<label class="form-label">اسم الحساب (إنجليزي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_en" class="form-input" value="<?= e($account['name_en']) ?>" required dir="ltr">
</div>
<div>
<label class="form-label">نوع الحساب <span style="color:#DC2626;">*</span></label>
<select name="account_type" class="form-select" required>
<option value="asset" <?= $account['account_type'] === 'asset' ? 'selected' : '' ?>>أصول</option>
<option value="liability" <?= $account['account_type'] === 'liability' ? 'selected' : '' ?>>خصوم</option>
<option value="equity" <?= $account['account_type'] === 'equity' ? 'selected' : '' ?>>حقوق ملكية</option>
<option value="revenue" <?= $account['account_type'] === 'revenue' ? 'selected' : '' ?>>إيرادات</option>
<option value="expense" <?= $account['account_type'] === 'expense' ? 'selected' : '' ?>>مصروفات</option>
</select>
</div>
<div>
<label class="form-label">طبيعة الحساب <span style="color:#DC2626;">*</span></label>
<select name="account_nature" class="form-select" required>
<option value="debit" <?= $account['account_nature'] === 'debit' ? 'selected' : '' ?>>مدين</option>
<option value="credit" <?= $account['account_nature'] === 'credit' ? 'selected' : '' ?>>دائن</option>
</select>
</div>
<div>
<label class="form-label">مركز التكلفة الافتراضي</label>
<select name="cost_center_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>" <?= (int)$cc['id'] === (int)($account['cost_center_id'] ?? 0) ? 'selected' : '' ?>><?= e($cc['code']) ?><?= e($cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_active" value="1" <?= (int)$account['is_active'] ? 'checked' : '' ?>> حساب نشط
</label>
<label class="form-label" style="display:flex;align-items:center;gap:8px;margin-top:8px;">
<input type="checkbox" name="is_header" value="1" <?= (int)$account['is_header'] ? 'checked' : '' ?>> حساب رئيسي
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">وصف (عربي)</label>
<textarea name="description_ar" class="form-textarea" rows="2"><?= e($account['description_ar'] ?? '') ?></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ التعديلات</button>
<a href="/accounting/chart-of-accounts" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>دليل الحسابات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/chart-of-accounts/create" class="btn btn-primary">+ حساب جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">دليل الحسابات (شجرة الحسابات)</h3>
</div>
<div style="padding:15px 20px;">
<?php if (empty($tree)): ?>
<p style="color:#6B7280;text-align:center;padding:40px 0;">لا توجد حسابات — <a href="/accounting/chart-of-accounts/create">أضف حساباً جديداً</a></p>
<?php else: ?>
<?php
function renderAccountTree(array $accounts, int $depth = 0): void {
foreach ($accounts as $acc) {
$indent = $depth * 25;
$isHeader = (int)($acc['is_header'] ?? 0);
$weight = $isHeader ? '700' : '400';
$color = match($acc['account_type']) {
'asset' => '#0D7377',
'liability' => '#DC2626',
'equity' => '#7C3AED',
'revenue' => '#059669',
'expense' => '#F59E0B',
default => '#374151',
};
$typeLabel = match($acc['account_type']) {
'asset' => 'أصول',
'liability' => 'خصوم',
'equity' => 'حقوق ملكية',
'revenue' => 'إيرادات',
'expense' => 'مصروفات',
default => '',
};
?>
<div style="display:flex;align-items:center;padding:8px 0;border-bottom:1px solid #F3F4F6;margin-right:<?= $indent ?>px;">
<span style="width:80px;direction:ltr;text-align:right;color:<?= $color ?>;font-weight:600;font-size:13px;"><?= e($acc['account_code']) ?></span>
<span style="flex:1;margin:0 15px;font-weight:<?= $weight ?>;"><?= e($acc['name_ar']) ?></span>
<span style="width:80px;font-size:12px;color:#6B7280;"><?= $typeLabel ?></span>
<span style="width:100px;direction:ltr;text-align:left;font-size:13px;"><?= !$isHeader ? money($acc['current_balance'] ?? '0.00') : '' ?></span>
<a href="/accounting/chart-of-accounts/<?= (int)$acc['id'] ?>/edit" style="font-size:12px;color:#0D7377;">تعديل</a>
</div>
<?php
if (!empty($acc['children'])) {
renderAccountTree($acc['children'], $depth + 1);
}
}
}
renderAccountTree($tree);
?>
<?php endif; ?>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مركز تكلفة جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إضافة مركز تكلفة / ربحية</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/cost-centers">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-input" required dir="ltr">
</div>
<div>
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="type" class="form-select" required>
<option value="cost_center">مركز تكلفة</option>
<option value="profit_center">مركز ربحية</option>
</select>
</div>
<div>
<label class="form-label">الاسم (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" required>
</div>
<div>
<label class="form-label">الاسم (إنجليزي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_en" class="form-input" required dir="ltr">
</div>
<div>
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>"><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label">المركز الأب</label>
<select name="parent_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($parents as $p): ?>
<option value="<?= (int)$p['id'] ?>"><?= e($p['code']) ?><?= e($p['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">وصف</label>
<textarea name="description" class="form-textarea" rows="2"></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ</button>
<a href="/accounting/cost-centers" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تعديل مركز تكلفة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">تعديل: <?= e($center['code']) ?><?= e($center['name_ar']) ?></h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/cost-centers/<?= (int)$center['id'] ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الكود</label>
<input type="text" class="form-input" value="<?= e($center['code']) ?>" disabled dir="ltr" style="background:#F3F4F6;">
</div>
<div>
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="type" class="form-select" required>
<option value="cost_center" <?= $center['type'] === 'cost_center' ? 'selected' : '' ?>>مركز تكلفة</option>
<option value="profit_center" <?= $center['type'] === 'profit_center' ? 'selected' : '' ?>>مركز ربحية</option>
</select>
</div>
<div>
<label class="form-label">الاسم (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" value="<?= e($center['name_ar']) ?>" required>
</div>
<div>
<label class="form-label">الاسم (إنجليزي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_en" class="form-input" value="<?= e($center['name_en']) ?>" required dir="ltr">
</div>
<div>
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>" <?= (int)$b['id'] === (int)($center['branch_id'] ?? 0) ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_active" value="1" <?= (int)$center['is_active'] ? 'checked' : '' ?>> نشط
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">وصف</label>
<textarea name="description" class="form-textarea" rows="2"><?= e($center['description'] ?? '') ?></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ</button>
<a href="/accounting/cost-centers" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مراكز التكلفة<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/cost-centers/create" class="btn btn-primary">+ مركز تكلفة جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الكود</th>
<th>الاسم</th>
<th>النوع</th>
<th>الفرع</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php
$branchMap = [];
foreach ($branches as $b) $branchMap[(int)$b['id']] = $b['name_ar'];
?>
<?php foreach ($centers as $cc): ?>
<tr>
<td style="font-weight:600;direction:ltr;text-align:right;"><?= e($cc['code']) ?></td>
<td><?= e($cc['name_ar']) ?></td>
<td><?= $cc['type'] === 'cost_center' ? 'مركز تكلفة' : 'مركز ربحية' ?></td>
<td><?= e($branchMap[(int)($cc['branch_id'] ?? 0)] ?? '—') ?></td>
<td><span style="color:<?= (int)$cc['is_active'] ? '#059669' : '#DC2626' ?>;"><?= (int)$cc['is_active'] ? 'نشط' : 'موقف' ?></span></td>
<td><a href="/accounting/cost-centers/<?= (int)$cc['id'] ?>/edit" class="btn btn-sm btn-outline">تعديل</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($centers)): ?>
<tr><td colspan="6" style="text-align:center;color:#6B7280;padding:30px;">لا توجد مراكز تكلفة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>المحاسبة والدفتر العام<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="margin-bottom:25px;">
<h2 style="margin:0;">لوحة تحكم المحاسبة</h2>
<?php if ($fiscal_year): ?>
<p style="color:#6B7280;margin:5px 0 0;">السنة المالية: <?= e($fiscal_year['name_ar']) ?> (<?= e($fiscal_year['status']) ?>)</p>
<?php else: ?>
<p style="color:#DC2626;margin:5px 0 0;">لا توجد سنة مالية حالية — <a href="/accounting/fiscal-years/create">إنشاء سنة مالية</a></p>
<?php endif; ?>
</div>
<!-- Quick Stats -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;margin-bottom:25px;">
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#0D7377;"><?= money($monthly_totals['total_debit'] ?? '0.00') ?></div>
<div style="color:#6B7280;margin-top:5px;">إجمالي حركة الشهر</div>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#059669;"><?= (int)($monthly_totals['count'] ?? 0) ?></div>
<div style="color:#6B7280;margin-top:5px;">قيود الشهر الحالي</div>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#DC2626;"><?= money($ar_summary['total'] ?? '0.00') ?></div>
<div style="color:#6B7280;margin-top:5px;">إجمالي المدينين</div>
</div>
<div class="card" style="padding:20px;text-align:center;">
<div style="font-size:28px;font-weight:700;color:#F59E0B;"><?= $draft_count ?></div>
<div style="color:#6B7280;margin-top:5px;">قيود مسودة</div>
</div>
</div>
<!-- Quick Actions -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:25px;">
<div class="card" style="padding:20px;">
<h3 style="margin:0 0 15px;">إجراءات سريعة</h3>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<a href="/accounting/journal-entries/create" class="btn btn-primary">إنشاء قيد يومية</a>
<a href="/accounting/reports/trial-balance" class="btn btn-outline">ميزان المراجعة</a>
<a href="/accounting/reports/income-statement" class="btn btn-outline">قائمة الدخل</a>
<a href="/accounting/reports/balance-sheet" class="btn btn-outline">الميزانية العمومية</a>
</div>
</div>
<div class="card" style="padding:20px;">
<h3 style="margin:0 0 15px;">تقادم المدينين (AR Aging)</h3>
<table style="width:100%;font-size:14px;">
<tr><td style="color:#6B7280;">جاري</td><td style="text-align:left;direction:ltr;"><?= money($ar_summary['current_amount'] ?? '0.00') ?></td></tr>
<tr><td style="color:#6B7280;">30 يوم</td><td style="text-align:left;direction:ltr;"><?= money($ar_summary['days_30'] ?? '0.00') ?></td></tr>
<tr><td style="color:#6B7280;">60 يوم</td><td style="text-align:left;direction:ltr;"><?= money($ar_summary['days_60'] ?? '0.00') ?></td></tr>
<tr><td style="color:#6B7280;">90 يوم</td><td style="text-align:left;direction:ltr;"><?= money($ar_summary['days_90'] ?? '0.00') ?></td></tr>
<tr><td style="color:#DC2626;font-weight:600;">أكثر من 90 يوم</td><td style="text-align:left;direction:ltr;color:#DC2626;font-weight:600;"><?= money($ar_summary['over_90'] ?? '0.00') ?></td></tr>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سنة مالية جديدة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إنشاء سنة مالية جديدة</h3>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/fiscal-years">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div>
<label class="form-label">الاسم (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-input" placeholder="مثال: السنة المالية 2025/2026" required>
</div>
<div>
<label class="form-label">الاسم (إنجليزي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_en" class="form-input" placeholder="e.g. FY 2025/2026" required dir="ltr">
</div>
<div>
<label class="form-label">تاريخ البداية <span style="color:#DC2626;">*</span></label>
<input type="date" name="start_date" class="form-input" value="<?= $current_year ?>-<?= str_pad((string)$start_month, 2, '0', STR_PAD_LEFT) ?>-01" required>
</div>
<div>
<label class="form-label">تاريخ النهاية <span style="color:#DC2626;">*</span></label>
<input type="date" name="end_date" class="form-input" value="<?= $current_year + 1 ?>-<?= str_pad((string)($start_month - 1 ?: 12), 2, '0', STR_PAD_LEFT) ?>-30" required>
</div>
<div>
<label class="form-label" style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" name="is_current" value="1" checked> تعيين كالسنة المالية الحالية
</label>
</div>
<div style="grid-column:1/-1;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">إنشاء السنة المالية</button>
<a href="/accounting/fiscal-years" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>السنوات المالية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/fiscal-years/create" class="btn btn-primary">+ سنة مالية جديدة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الاسم</th>
<th>تاريخ البداية</th>
<th>تاريخ النهاية</th>
<th>الحالة</th>
<th>الحالية</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($years as $fy): ?>
<?php
$statusColor = match($fy['status']) {
'open' => '#059669',
'closing' => '#F59E0B',
'closed' => '#6B7280',
default => '#374151',
};
$statusLabel = match($fy['status']) {
'open' => 'مفتوحة',
'closing' => 'قيد الإقفال',
'closed' => 'مغلقة',
default => $fy['status'],
};
?>
<tr>
<td style="font-weight:600;"><?= e($fy['name_ar']) ?></td>
<td><?= e($fy['start_date']) ?></td>
<td><?= e($fy['end_date']) ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;"><?= $statusLabel ?></span></td>
<td><?= (int)$fy['is_current'] ? '<span style="color:#059669;font-weight:700;">نعم</span>' : '—' ?></td>
<td><a href="/accounting/fiscal-years/<?= (int)$fy['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($years)): ?>
<tr><td colspan="6" style="text-align:center;color:#6B7280;padding:30px;">لا توجد سنوات مالية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($fiscal_year['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusColor = match($fiscal_year['status']) {
'open' => '#059669',
'closing' => '#F59E0B',
'closed' => '#6B7280',
default => '#374151',
};
$statusLabel = match($fiscal_year['status']) {
'open' => 'مفتوحة',
'closing' => 'قيد الإقفال',
'closed' => 'مغلقة',
default => $fiscal_year['status'],
};
?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<h2 style="margin:0;"><?= e($fiscal_year['name_ar']) ?></h2>
<span style="padding:8px 20px;border-radius:20px;font-weight:700;color:white;background:<?= $statusColor ?>;"><?= $statusLabel ?></span>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;">
<div><span style="color:#6B7280;">تاريخ البداية:</span><br><strong><?= e($fiscal_year['start_date']) ?></strong></div>
<div><span style="color:#6B7280;">تاريخ النهاية:</span><br><strong><?= e($fiscal_year['end_date']) ?></strong></div>
<div><span style="color:#6B7280;">الحالية:</span><br><strong><?= (int)$fiscal_year['is_current'] ? 'نعم' : 'لا' ?></strong></div>
</div>
<?php if ($fiscal_year['status'] === 'open'): ?>
<div style="margin-top:15px;">
<form method="POST" action="/accounting/fiscal-years/<?= (int)$fiscal_year['id'] ?>/close" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('هل أنت متأكد من إقفال السنة المالية؟ سيتم إنشاء قيد إقفال وترحيل الأرصدة.')">إقفال السنة المالية</button>
</form>
</div>
<?php endif; ?>
</div>
</div>
<!-- Periods Grid -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">الفترات الشهرية</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الفترة</th>
<th>من</th>
<th>إلى</th>
<th>عدد القيود</th>
<th>إجمالي الحركة</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($periods as $p): ?>
<?php
$pColor = match($p['status']) {
'open' => '#059669',
'closed' => '#6B7280',
'reopened' => '#F59E0B',
default => '#374151',
};
$pLabel = match($p['status']) {
'open' => 'مفتوحة',
'closed' => 'مغلقة',
'reopened' => 'أعيد فتحها',
default => $p['status'],
};
?>
<tr>
<td style="font-weight:600;"><?= e($p['period']) ?></td>
<td><?= e($p['period_start']) ?></td>
<td><?= e($p['period_end']) ?></td>
<td style="text-align:center;"><?= (int)$p['journal_count'] ?></td>
<td style="direction:ltr;text-align:right;"><?= money($p['total_debit']) ?></td>
<td><span style="color:<?= $pColor ?>;font-weight:600;"><?= $pLabel ?></span></td>
<td>
<?php if ($p['status'] === 'open' || $p['status'] === 'reopened'): ?>
<form method="POST" action="/accounting/period-closing/close-month" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="fiscal_year_id" value="<?= (int)$fiscal_year['id'] ?>">
<input type="hidden" name="period" value="<?= e($p['period']) ?>">
<button type="submit" class="btn btn-sm btn-outline" onclick="return confirm('إغلاق الفترة <?= e($p['period']) ?>?')">إغلاق</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قيد يومية جديد<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">إنشاء قيد يومية يدوي</h3>
<?php if ($fiscal_year): ?>
<p style="color:#6B7280;margin:5px 0 0;font-size:13px;">السنة المالية: <?= e($fiscal_year['name_ar']) ?></p>
<?php endif; ?>
</div>
<div style="padding:20px;">
<form method="POST" action="/accounting/journal-entries" id="journalForm">
<?= csrf_field() ?>
<!-- Header -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:20px;">
<div>
<label class="form-label">تاريخ القيد <span style="color:#DC2626;">*</span></label>
<input type="date" name="entry_date" class="form-input" value="<?= date('Y-m-d') ?>" required>
</div>
<div>
<label class="form-label">رقم المرجع</label>
<input type="text" name="reference_number" class="form-input" dir="ltr">
</div>
<div>
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>"><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:15px;margin-bottom:20px;">
<div>
<label class="form-label">الوصف (عربي) <span style="color:#DC2626;">*</span></label>
<input type="text" name="description_ar" class="form-input" required>
</div>
<div>
<label class="form-label">مركز التكلفة</label>
<select name="cost_center_id" class="form-select">
<option value="">— بدون —</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>"><?= e($cc['code']) ?><?= e($cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<!-- Lines -->
<div style="border:1px solid #E5E7EB;border-radius:8px;overflow:hidden;margin-bottom:20px;">
<div style="padding:10px 15px;background:#F9FAFB;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<strong>سطور القيد</strong>
<button type="button" class="btn btn-sm btn-outline" onclick="addLine()">+ سطر جديد</button>
</div>
<table style="width:100%;" id="linesTable">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:8px;text-align:right;width:35%;">الحساب</th>
<th style="padding:8px;text-align:right;width:20%;">البيان</th>
<th style="padding:8px;text-align:center;width:18%;">مدين</th>
<th style="padding:8px;text-align:center;width:18%;">دائن</th>
<th style="padding:8px;width:9%;"></th>
</tr>
</thead>
<tbody id="linesBody">
<tr>
<td style="padding:5px;">
<select name="line_account_id[]" class="form-select" required>
<option value="">— اختر حساباً —</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= (int)$acc['id'] ?>"><?= e($acc['account_code']) ?><?= e($acc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</td>
<td style="padding:5px;"><input type="text" name="line_description[]" class="form-input" style="font-size:13px;"></td>
<td style="padding:5px;"><input type="number" name="line_debit[]" class="form-input debit-input" value="0.00" step="0.01" min="0" dir="ltr" onchange="calcTotals()"></td>
<td style="padding:5px;"><input type="number" name="line_credit[]" class="form-input credit-input" value="0.00" step="0.01" min="0" dir="ltr" onchange="calcTotals()"></td>
<td style="padding:5px;text-align:center;"><button type="button" class="btn btn-sm" style="color:#DC2626;" onclick="removeLine(this)">X</button></td>
</tr>
<tr>
<td style="padding:5px;">
<select name="line_account_id[]" class="form-select" required>
<option value="">— اختر حساباً —</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= (int)$acc['id'] ?>"><?= e($acc['account_code']) ?><?= e($acc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</td>
<td style="padding:5px;"><input type="text" name="line_description[]" class="form-input" style="font-size:13px;"></td>
<td style="padding:5px;"><input type="number" name="line_debit[]" class="form-input debit-input" value="0.00" step="0.01" min="0" dir="ltr" onchange="calcTotals()"></td>
<td style="padding:5px;"><input type="number" name="line_credit[]" class="form-input credit-input" value="0.00" step="0.01" min="0" dir="ltr" onchange="calcTotals()"></td>
<td style="padding:5px;text-align:center;"><button type="button" class="btn btn-sm" style="color:#DC2626;" onclick="removeLine(this)">X</button></td>
</tr>
</tbody>
<tfoot>
<tr style="background:#F0FDF4;font-weight:700;">
<td style="padding:10px;" colspan="2">الإجمالي</td>
<td style="padding:10px;text-align:center;direction:ltr;" id="totalDebit">0.00</td>
<td style="padding:10px;text-align:center;direction:ltr;" id="totalCredit">0.00</td>
<td></td>
</tr>
<tr id="differenceRow" style="display:none;background:#FEF2F2;">
<td style="padding:10px;color:#DC2626;" colspan="2">الفرق</td>
<td style="padding:10px;text-align:center;color:#DC2626;direction:ltr;" colspan="2" id="difference">0.00</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div>
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-textarea" rows="2"></textarea>
</div>
<div style="margin-top:20px;display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">حفظ كمسودة</button>
<a href="/accounting/journal-entries" class="btn btn-outline">إلغاء</a>
</div>
</form>
</div>
</div>
<script>
function addLine() {
const tbody = document.getElementById('linesBody');
const firstRow = tbody.querySelector('tr');
const newRow = firstRow.cloneNode(true);
newRow.querySelectorAll('input').forEach(i => { if(i.type === 'number') i.value = '0.00'; else i.value = ''; });
newRow.querySelectorAll('select').forEach(s => s.selectedIndex = 0);
tbody.appendChild(newRow);
}
function removeLine(btn) {
const tbody = document.getElementById('linesBody');
if (tbody.querySelectorAll('tr').length > 2) {
btn.closest('tr').remove();
calcTotals();
}
}
function calcTotals() {
let totalDebit = 0, totalCredit = 0;
document.querySelectorAll('.debit-input').forEach(i => totalDebit += parseFloat(i.value) || 0);
document.querySelectorAll('.credit-input').forEach(i => totalCredit += parseFloat(i.value) || 0);
document.getElementById('totalDebit').textContent = totalDebit.toFixed(2);
document.getElementById('totalCredit').textContent = totalCredit.toFixed(2);
const diff = Math.abs(totalDebit - totalCredit);
const diffRow = document.getElementById('differenceRow');
if (diff > 0.001) {
diffRow.style.display = '';
document.getElementById('difference').textContent = diff.toFixed(2);
} else {
diffRow.style.display = 'none';
}
}
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قيود اليومية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/accounting/journal-entries/create" class="btn btn-primary">+ قيد جديد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/journal-entries" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="search" class="form-input" value="<?= e($filters['search'] ?? '') ?>" placeholder="رقم القيد، الوصف...">
</div>
<div>
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<option value="draft" <?= ($filters['status'] ?? '') === 'draft' ? 'selected' : '' ?>>مسودة</option>
<option value="posted" <?= ($filters['status'] ?? '') === 'posted' ? 'selected' : '' ?>>مرحّل</option>
<option value="reversed" <?= ($filters['status'] ?? '') === 'reversed' ? 'selected' : '' ?>>معكوس</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">المصدر</label>
<select name="source_module" class="form-select">
<option value="">الكل</option>
<option value="payments" <?= ($filters['source_module'] ?? '') === 'payments' ? 'selected' : '' ?>>المدفوعات</option>
<option value="sales" <?= ($filters['source_module'] ?? '') === 'sales' ? 'selected' : '' ?>>المبيعات</option>
<option value="hr" <?= ($filters['source_module'] ?? '') === 'hr' ? 'selected' : '' ?>>الموارد البشرية</option>
<option value="accounting" <?= ($filters['source_module'] ?? '') === 'accounting' ? 'selected' : '' ?>>المحاسبة</option>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($filters['date_from'] ?? '') ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($filters['date_to'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
</div>
</div>
<!-- Results -->
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>رقم القيد</th>
<th>التاريخ</th>
<th>الوصف</th>
<th>المدين</th>
<th>الدائن</th>
<th>المصدر</th>
<th>الحالة</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $entry): ?>
<?php
$statusColor = match($entry['status']) {
'draft' => '#F59E0B',
'posted' => '#059669',
'reversed' => '#DC2626',
default => '#6B7280',
};
$statusLabel = match($entry['status']) {
'draft' => 'مسودة',
'posted' => 'مرحّل',
'reversed' => 'معكوس',
default => $entry['status'],
};
$sourceLabel = match($entry['source_module'] ?? '') {
'payments' => 'مدفوعات',
'sales' => 'مبيعات',
'hr' => 'رواتب',
'fines' => 'غرامات',
'installments' => 'أقساط',
'accounting' => 'محاسبة',
default => $entry['source_module'] ?? 'يدوي',
};
?>
<tr<?= $entry['status'] === 'reversed' ? ' style="opacity:0.5;text-decoration:line-through;"' : '' ?>>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($entry['entry_number']) ?></td>
<td><?= e($entry['entry_date']) ?></td>
<td><?= e(mb_substr($entry['description_ar'], 0, 60)) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($entry['total_debit']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($entry['total_credit']) ?></td>
<td style="font-size:12px;"><?= $sourceLabel ?></td>
<td><span style="color:<?= $statusColor ?>;font-weight:600;font-size:13px;"><?= $statusLabel ?></span></td>
<td><a href="/accounting/journal-entries/<?= (int)$entry['id'] ?>" class="btn btn-sm btn-outline">عرض</a></td>
</tr>
<?php endforeach; ?>
<?php if (empty($entries)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد قيود</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
<?php if ($pages > 1): ?>
<div style="padding:15px 20px;display:flex;justify-content:center;gap:5px;">
<?php for ($p = 1; $p <= $pages; $p++): ?>
<a href="?page=<?= $p ?>&<?= http_build_query(array_filter($filters)) ?>"
class="btn btn-sm <?= $p === $page ? 'btn-primary' : 'btn-outline' ?>"><?= $p ?></a>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قيد <?= e($entry['entry_number']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusColor = match($entry['status']) {
'draft' => '#F59E0B',
'posted' => '#059669',
'reversed' => '#DC2626',
default => '#6B7280',
};
$statusLabel = match($entry['status']) {
'draft' => 'مسودة',
'posted' => 'مرحّل',
'reversed' => 'معكوس',
default => $entry['status'],
};
?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:20px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
<div>
<h2 style="margin:0;">قيد رقم: <span style="direction:ltr;"><?= e($entry['entry_number']) ?></span></h2>
<p style="color:#6B7280;margin:5px 0 0;"><?= e($entry['description_ar']) ?></p>
</div>
<span style="display:inline-block;padding:8px 20px;border-radius:20px;font-weight:700;color:white;background:<?= $statusColor ?>;"><?= $statusLabel ?></span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:15px;font-size:14px;">
<div><span style="color:#6B7280;">التاريخ:</span><br><strong><?= e($entry['entry_date']) ?></strong></div>
<div><span style="color:#6B7280;">المصدر:</span><br><strong><?= e($entry['source_module'] ?? 'يدوي') ?></strong></div>
<div><span style="color:#6B7280;">أنشأه:</span><br><strong><?= e($created_by) ?></strong></div>
<div><span style="color:#6B7280;">رحّله:</span><br><strong><?= e($posted_by) ?></strong></div>
</div>
<?php if ($entry['reference_number']): ?>
<div style="margin-top:10px;font-size:14px;"><span style="color:#6B7280;">رقم المرجع:</span> <strong style="direction:ltr;"><?= e($entry['reference_number']) ?></strong></div>
<?php endif; ?>
<!-- Actions -->
<div style="margin-top:20px;display:flex;gap:8px;">
<?php if ($entry['status'] === 'draft'): ?>
<form method="POST" action="/accounting/journal-entries/<?= (int)$entry['id'] ?>/post" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-primary" onclick="return confirm('هل تريد ترحيل هذا القيد؟')">ترحيل القيد</button>
</form>
<?php endif; ?>
<?php if ($entry['status'] === 'posted' && !$entry['reversal_of_id']): ?>
<form method="POST" action="/accounting/journal-entries/<?= (int)$entry['id'] ?>/reverse" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="reason" value="عكس يدوي">
<button type="submit" class="btn" style="color:#DC2626;border-color:#DC2626;" onclick="return confirm('هل تريد عكس هذا القيد؟ سيتم إنشاء قيد عكسي.')">عكس القيد</button>
</form>
<?php endif; ?>
<a href="/accounting/journal-entries" class="btn btn-outline">رجوع</a>
</div>
</div>
</div>
<!-- Lines -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">سطور القيد</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th style="width:5%;">#</th>
<th style="width:12%;">كود الحساب</th>
<th style="width:30%;">اسم الحساب</th>
<th style="width:20%;">البيان</th>
<th style="width:15%;">مدين</th>
<th style="width:15%;">دائن</th>
</tr>
</thead>
<tbody>
<?php foreach ($lines as $line): ?>
<tr>
<td><?= (int)$line['line_number'] ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= e($line['account_code']) ?></td>
<td><?= e($line['account_name_ar']) ?></td>
<td style="font-size:13px;color:#6B7280;"><?= e($line['description_ar'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;<?= bccomp((string)$line['debit'], '0.00', 2) > 0 ? 'color:#0D7377;font-weight:600;' : '' ?>"><?= money($line['debit']) ?></td>
<td style="direction:ltr;text-align:right;<?= bccomp((string)$line['credit'], '0.00', 2) > 0 ? 'color:#059669;font-weight:600;' : '' ?>"><?= money($line['credit']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;background:#F9FAFB;">
<td colspan="4" style="padding:12px;">الإجمالي</td>
<td style="direction:ltr;text-align:right;padding:12px;"><?= money($entry['total_debit']) ?></td>
<td style="direction:ltr;text-align:right;padding:12px;"><?= money($entry['total_credit']) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php if ($entry['notes']): ?>
<div class="card" style="margin-top:15px;padding:15px 20px;">
<strong>ملاحظات:</strong>
<p style="margin:5px 0 0;"><?= e($entry['notes']) ?></p>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إقفال الفترات<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Year Selector -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/period-closing" style="display:flex;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">السنة المالية</label>
<select name="fiscal_year_id" class="form-select" onchange="this.form.submit()">
<option value="">— اختر —</option>
<?php foreach ($fiscal_years as $fy): ?>
<option value="<?= (int)$fy['id'] ?>" <?= (int)$fy['id'] === $fiscal_year_id ? 'selected' : '' ?>><?= e($fy['name_ar']) ?> (<?= e($fy['status']) ?>)</option>
<?php endforeach; ?>
</select>
</div>
</form>
</div>
</div>
<?php if ($fiscal_year): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;"><?= e($fiscal_year['name_ar']) ?> — الفترات الشهرية</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>الفترة</th>
<th>من</th>
<th>إلى</th>
<th>عدد القيود</th>
<th>إجمالي الحركة</th>
<th>الحالة</th>
<th>تاريخ الإغلاق</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($periods as $p): ?>
<?php
$pColor = match($p['status']) {
'open' => '#059669',
'closed' => '#6B7280',
'reopened' => '#F59E0B',
'closing' => '#0284C7',
default => '#374151',
};
$pLabel = match($p['status']) {
'open' => 'مفتوحة',
'closed' => 'مغلقة',
'reopened' => 'أعيد فتحها',
'closing' => 'قيد الإغلاق',
default => $p['status'],
};
?>
<tr>
<td style="font-weight:600;"><?= e($p['period']) ?></td>
<td><?= e($p['period_start']) ?></td>
<td><?= e($p['period_end']) ?></td>
<td style="text-align:center;"><?= (int)$p['journal_count'] ?></td>
<td style="direction:ltr;text-align:right;"><?= money($p['total_debit']) ?></td>
<td><span style="color:<?= $pColor ?>;font-weight:600;"><?= $pLabel ?></span></td>
<td style="font-size:12px;color:#6B7280;"><?= e($p['closed_at'] ?? '—') ?></td>
<td>
<?php if ($p['status'] === 'open' || $p['status'] === 'reopened'): ?>
<form method="POST" action="/accounting/period-closing/close-month" style="display:inline;">
<?= csrf_field() ?>
<input type="hidden" name="fiscal_year_id" value="<?= $fiscal_year_id ?>">
<input type="hidden" name="period" value="<?= e($p['period']) ?>">
<button type="submit" class="btn btn-sm btn-outline" onclick="return confirm('إغلاق الفترة <?= e($p['period']) ?>?')">إغلاق</button>
</form>
<?php elseif ($p['status'] === 'closed'): ?>
<form method="POST" action="/accounting/period-closing/reopen-month" style="display:inline;" onsubmit="var r=prompt('سبب إعادة الفتح:');if(!r)return false;this.querySelector('[name=reason]').value=r;">
<?= csrf_field() ?>
<input type="hidden" name="fiscal_year_id" value="<?= $fiscal_year_id ?>">
<input type="hidden" name="period" value="<?= e($p['period']) ?>">
<input type="hidden" name="reason" value="">
<button type="submit" class="btn btn-sm" style="color:#F59E0B;border-color:#F59E0B;">إعادة فتح</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير الدائنين<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Aging Summary -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:20px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">جاري</div>
<div style="font-size:18px;font-weight:700;color:#059669;"><?= money($aging['current_amount'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">30 يوم</div>
<div style="font-size:18px;font-weight:700;color:#F59E0B;"><?= money($aging['days_30'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">60 يوم</div>
<div style="font-size:18px;font-weight:700;color:#F97316;"><?= money($aging['days_60'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">90 يوم</div>
<div style="font-size:18px;font-weight:700;color:#DC2626;"><?= money($aging['days_90'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">+90 يوم</div>
<div style="font-size:18px;font-weight:700;color:#991B1B;"><?= money($aging['over_90'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;background:#FEF2F2;">
<div style="font-size:12px;color:#6B7280;">الإجمالي</div>
<div style="font-size:18px;font-weight:700;color:#DC2626;"><?= money($aging['total'] ?? '0.00') ?></div>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">الدائنون المستحقة</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>المورد</th>
<th>رقم الفاتورة</th>
<th>تاريخ الاستحقاق</th>
<th>المبلغ</th>
<th>المدفوع</th>
<th>الرصيد</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($outstanding as $ap): ?>
<?php $isOverdue = strtotime($ap['due_date']) < time(); ?>
<tr>
<td><?= e($ap['supplier_name'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($ap['invoice_number']) ?></td>
<td style="<?= $isOverdue ? 'color:#DC2626;font-weight:600;' : '' ?>"><?= e($ap['due_date']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($ap['total_amount']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($ap['paid_amount']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($ap['balance']) ?></td>
<td style="font-size:12px;color:<?= $isOverdue ? '#DC2626' : '#F59E0B' ?>;"><?= $isOverdue ? 'متأخر' : 'مستحق' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($outstanding)): ?>
<tr><td colspan="7" style="text-align:center;color:#6B7280;padding:30px;">لا توجد التزامات مستحقة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير المدينين<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Aging Summary -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:20px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">جاري</div>
<div style="font-size:18px;font-weight:700;color:#059669;"><?= money($aging['current_amount'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">30 يوم</div>
<div style="font-size:18px;font-weight:700;color:#F59E0B;"><?= money($aging['days_30'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">60 يوم</div>
<div style="font-size:18px;font-weight:700;color:#F97316;"><?= money($aging['days_60'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">90 يوم</div>
<div style="font-size:18px;font-weight:700;color:#DC2626;"><?= money($aging['days_90'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:12px;color:#6B7280;">+90 يوم</div>
<div style="font-size:18px;font-weight:700;color:#991B1B;"><?= money($aging['over_90'] ?? '0.00') ?></div>
</div>
<div class="card" style="padding:15px;text-align:center;background:#FEF2F2;">
<div style="font-size:12px;color:#6B7280;">الإجمالي</div>
<div style="font-size:18px;font-weight:700;color:#DC2626;"><?= money($aging['total'] ?? '0.00') ?></div>
</div>
</div>
<!-- Outstanding List -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">المدينين المستحقة</h3>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>العضو</th>
<th>النوع</th>
<th>الوصف</th>
<th>تاريخ الاستحقاق</th>
<th>المبلغ</th>
<th>المدفوع</th>
<th>الرصيد</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($outstanding as $ar): ?>
<?php
$typeLabel = match($ar['document_type'] ?? '') {
'installment' => 'أقساط',
'subscription' => 'اشتراك',
'fine' => 'غرامة',
'membership_fee' => 'عضوية',
default => $ar['document_type'] ?? '—',
};
$isOverdue = strtotime($ar['due_date']) < time();
?>
<tr>
<td><?= e($ar['member_name'] ?? '—') ?></td>
<td style="font-size:12px;"><?= $typeLabel ?></td>
<td style="font-size:13px;"><?= e($ar['description_ar']) ?></td>
<td style="<?= $isOverdue ? 'color:#DC2626;font-weight:600;' : '' ?>"><?= e($ar['due_date']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($ar['total_amount']) ?></td>
<td style="direction:ltr;text-align:right;"><?= money($ar['paid_amount']) ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($ar['balance']) ?></td>
<td style="font-size:12px;color:<?= $isOverdue ? '#DC2626' : '#F59E0B' ?>;"><?= $isOverdue ? 'متأخر' : 'مستحق' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($outstanding)): ?>
<tr><td colspan="8" style="text-align:center;color:#6B7280;padding:30px;">لا توجد مديونيات مستحقة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الميزانية العمومية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/balance-sheet" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">حتى تاريخ</label>
<input type="date" name="as_of_date" class="form-input" value="<?= e($as_of_date) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>" <?= (int)($filters['branch_id'] ?? 0) === (int)$b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;text-align:center;">
<h2 style="margin:0;">الميزانية العمومية</h2>
<p style="color:#6B7280;margin:5px 0 0;">حتى تاريخ <?= e($as_of_date) ?></p>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:30px;">
<!-- Assets -->
<div>
<h3 style="color:#0D7377;border-bottom:2px solid #0D7377;padding-bottom:8px;">الأصول</h3>
<table style="width:100%;">
<?php foreach ($result['assets'] as $a): ?>
<tr>
<td style="padding:5px 0;font-size:13px;color:#6B7280;width:15%;direction:ltr;text-align:right;"><?= e($a['account_code']) ?></td>
<td style="padding:5px 0;"><?= e($a['name_ar']) ?></td>
<td style="padding:5px 0;direction:ltr;text-align:left;width:25%;"><?= money($a['balance']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:700;border-top:3px double #0D7377;">
<td style="padding:12px 0;" colspan="2">إجمالي الأصول</td>
<td style="padding:12px 0;direction:ltr;text-align:left;color:#0D7377;font-size:20px;"><?= money($result['total_assets']) ?></td>
</tr>
</table>
</div>
<!-- Liabilities + Equity -->
<div>
<h3 style="color:#DC2626;border-bottom:2px solid #DC2626;padding-bottom:8px;">الخصوم</h3>
<table style="width:100%;">
<?php foreach ($result['liabilities'] as $l): ?>
<tr>
<td style="padding:5px 0;font-size:13px;color:#6B7280;width:15%;direction:ltr;text-align:right;"><?= e($l['account_code']) ?></td>
<td style="padding:5px 0;"><?= e($l['name_ar']) ?></td>
<td style="padding:5px 0;direction:ltr;text-align:left;width:25%;"><?= money($l['balance']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:600;border-top:1px solid #DC2626;">
<td style="padding:8px 0;" colspan="2">إجمالي الخصوم</td>
<td style="padding:8px 0;direction:ltr;text-align:left;"><?= money($result['total_liabilities']) ?></td>
</tr>
</table>
<h3 style="color:#7C3AED;border-bottom:2px solid #7C3AED;padding-bottom:8px;margin-top:20px;">حقوق الملكية</h3>
<table style="width:100%;">
<?php foreach ($result['equity'] as $eq): ?>
<tr>
<td style="padding:5px 0;font-size:13px;color:#6B7280;width:15%;direction:ltr;text-align:right;"><?= e($eq['account_code']) ?></td>
<td style="padding:5px 0;"><?= e($eq['name_ar']) ?></td>
<td style="padding:5px 0;direction:ltr;text-align:left;width:25%;"><?= money($eq['balance']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:600;border-top:1px solid #7C3AED;">
<td style="padding:8px 0;" colspan="2">إجمالي حقوق الملكية</td>
<td style="padding:8px 0;direction:ltr;text-align:left;"><?= money($result['total_equity']) ?></td>
</tr>
</table>
<div style="font-weight:700;border-top:3px double #374151;padding:12px 0;margin-top:10px;display:flex;justify-content:space-between;">
<span>إجمالي الخصوم + حقوق الملكية</span>
<span style="direction:ltr;font-size:20px;"><?= money($result['total_liabilities_and_equity']) ?></span>
</div>
</div>
</div>
<!-- Balance Check -->
<div style="margin-top:20px;text-align:center;padding:15px;border-radius:8px;background:<?= $result['is_balanced'] ? '#F0FDF4' : '#FEF2F2' ?>;">
<?php if ($result['is_balanced']): ?>
<span style="color:#059669;font-weight:700;font-size:16px;">الميزانية متوازنة (الأصول = الخصوم + حقوق الملكية)</span>
<?php else: ?>
<span style="color:#DC2626;font-weight:700;font-size:16px;">تحذير: الميزانية غير متوازنة — فرق: <?= money(bcsub($result['total_assets'], $result['total_liabilities_and_equity'], 2)) ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>ميزانية موحدة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/consolidated-balance-sheet" style="display:flex;gap:10px;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">حتى تاريخ</label>
<input type="date" name="as_of_date" class="form-input" value="<?= e($as_of_date) ?>">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<!-- Consolidated -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;text-align:center;background:#0D7377;color:white;">
<h2 style="margin:0;">الميزانية الموحدة</h2>
<p style="margin:5px 0 0;opacity:0.8;">حتى تاريخ <?= e($as_of_date) ?></p>
</div>
<div style="padding:20px;display:grid;grid-template-columns:repeat(3,1fr);gap:20px;text-align:center;">
<div>
<div style="font-size:14px;color:#6B7280;">إجمالي الأصول</div>
<div style="font-size:24px;font-weight:700;color:#0D7377;"><?= money($result['consolidated']['total_assets']) ?></div>
</div>
<div>
<div style="font-size:14px;color:#6B7280;">إجمالي الخصوم</div>
<div style="font-size:24px;font-weight:700;color:#DC2626;"><?= money($result['consolidated']['total_liabilities']) ?></div>
</div>
<div>
<div style="font-size:14px;color:#6B7280;">حقوق الملكية</div>
<div style="font-size:24px;font-weight:700;color:#7C3AED;"><?= money($result['consolidated']['total_equity']) ?></div>
</div>
</div>
</div>
<!-- Per Branch -->
<?php foreach ($result['branch_sheets'] as $bs): ?>
<div class="card" style="margin-bottom:15px;">
<div style="padding:12px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;"><?= e($bs['branch']['name_ar']) ?></h3>
</div>
<div style="padding:15px 20px;display:grid;grid-template-columns:repeat(3,1fr);gap:15px;text-align:center;">
<div>
<div style="font-size:12px;color:#6B7280;">الأصول</div>
<div style="font-size:18px;font-weight:600;"><?= money($bs['balance_sheet']['total_assets']) ?></div>
</div>
<div>
<div style="font-size:12px;color:#6B7280;">الخصوم</div>
<div style="font-size:18px;font-weight:600;"><?= money($bs['balance_sheet']['total_liabilities']) ?></div>
</div>
<div>
<div style="font-size:12px;color:#6B7280;">حقوق الملكية</div>
<div style="font-size:18px;font-weight:600;"><?= money($bs['balance_sheet']['total_equity']) ?></div>
</div>
</div>
</div>
<?php endforeach; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>دفتر الأستاذ العام<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/general-ledger" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:2;">
<label class="form-label" style="font-size:12px;">الحساب</label>
<input type="number" name="account_id" class="form-input" value="<?= $account_id ?>" placeholder="رقم الحساب (ID)">
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($date_from) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($date_to) ?>">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<?php if ($ledger && $ledger['account']): ?>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;">
<h3 style="margin:0;">
<span style="direction:ltr;color:#0D7377;"><?= e($ledger['account']['account_code']) ?></span>
<?= e($ledger['account']['name_ar']) ?>
</h3>
<p style="color:#6B7280;margin:5px 0 0;font-size:13px;">من <?= e($date_from) ?> إلى <?= e($date_to) ?></p>
</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>
<!-- Opening Balance Row -->
<tr style="background:#F9FAFB;font-weight:600;">
<td colspan="4">رصيد أول المدة</td>
<td></td>
<td></td>
<td style="direction:ltr;text-align:right;"><?= money($ledger['opening_balance']) ?></td>
</tr>
<?php foreach ($ledger['entries'] as $e_row): ?>
<tr>
<td><?= e($e_row['entry_date']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;"><?= e($e_row['entry_number']) ?></td>
<td style="font-size:13px;"><?= e($e_row['description_ar'] ?? $e_row['entry_description'] ?? '') ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($e_row['reference_number'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;"><?= bccomp((string)$e_row['debit'], '0.00', 2) > 0 ? money($e_row['debit']) : '' ?></td>
<td style="direction:ltr;text-align:right;"><?= bccomp((string)$e_row['credit'], '0.00', 2) > 0 ? money($e_row['credit']) : '' ?></td>
<td style="direction:ltr;text-align:right;font-weight:600;"><?= money($e_row['running_balance']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;background:#F0FDF4;">
<td style="padding:12px;" colspan="4">رصيد آخر المدة</td>
<td></td>
<td></td>
<td style="direction:ltr;text-align:right;padding:12px;font-size:16px;"><?= money($ledger['closing_balance']) ?></td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php elseif ($account_id > 0): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">الحساب غير موجود</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">اختر حساباً لعرض دفتر الأستاذ</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قائمة الدخل<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/income-statement" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($date_from) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($date_to) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>" <?= (int)($filters['branch_id'] ?? 0) === (int)$b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;text-align:center;">
<h2 style="margin:0;">قائمة الدخل</h2>
<p style="color:#6B7280;margin:5px 0 0;">من <?= e($date_from) ?> إلى <?= e($date_to) ?></p>
</div>
<div style="padding:20px;">
<!-- Revenue -->
<div style="margin-bottom:25px;">
<h3 style="color:#059669;border-bottom:2px solid #059669;padding-bottom:8px;">الإيرادات</h3>
<table style="width:100%;">
<?php foreach ($result['revenue'] as $r): ?>
<tr>
<td style="padding:6px 0;width:15%;direction:ltr;text-align:right;font-size:13px;color:#6B7280;"><?= e($r['account_code']) ?></td>
<td style="padding:6px 0;"><?= e($r['name_ar']) ?></td>
<td style="padding:6px 0;width:20%;direction:ltr;text-align:left;"><?= money($r['balance']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:700;border-top:2px solid #059669;">
<td style="padding:10px 0;" colspan="2">إجمالي الإيرادات</td>
<td style="padding:10px 0;direction:ltr;text-align:left;color:#059669;font-size:18px;"><?= money($result['total_revenue']) ?></td>
</tr>
</table>
</div>
<!-- Expenses -->
<div style="margin-bottom:25px;">
<h3 style="color:#DC2626;border-bottom:2px solid #DC2626;padding-bottom:8px;">المصروفات</h3>
<table style="width:100%;">
<?php foreach ($result['expenses'] as $exp): ?>
<tr>
<td style="padding:6px 0;width:15%;direction:ltr;text-align:right;font-size:13px;color:#6B7280;"><?= e($exp['account_code']) ?></td>
<td style="padding:6px 0;"><?= e($exp['name_ar']) ?></td>
<td style="padding:6px 0;width:20%;direction:ltr;text-align:left;"><?= money($exp['balance']) ?></td>
</tr>
<?php endforeach; ?>
<tr style="font-weight:700;border-top:2px solid #DC2626;">
<td style="padding:10px 0;" colspan="2">إجمالي المصروفات</td>
<td style="padding:10px 0;direction:ltr;text-align:left;color:#DC2626;font-size:18px;"><?= money($result['total_expenses']) ?></td>
</tr>
</table>
</div>
<!-- Net Income -->
<div style="background:<?= bccomp($result['net_income'], '0.00', 2) >= 0 ? '#F0FDF4' : '#FEF2F2' ?>;padding:20px;border-radius:8px;text-align:center;">
<div style="font-size:14px;color:#6B7280;">صافي الربح / (الخسارة)</div>
<div style="font-size:32px;font-weight:700;color:<?= bccomp($result['net_income'], '0.00', 2) >= 0 ? '#059669' : '#DC2626' ?>;">
<?= money($result['net_income']) ?> ج.م
</div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>كشف حساب عضو<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Search -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/member-statement" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">رقم العضو</label>
<input type="number" name="member_id" class="form-input" value="<?= $member_id ?>" placeholder="ID العضو">
</div>
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($date_from) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($date_to) ?>">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<?php if ($member): ?>
<div class="card" style="margin-bottom:15px;padding:15px 20px;">
<h3 style="margin:0;">كشف حساب: <?= e($member['full_name_ar']) ?> (استمارة: <?= e($member['form_number'] ?? '—') ?>)</h3>
<p style="color:#6B7280;margin:5px 0 0;font-size:13px;">من <?= e($date_from) ?> إلى <?= e($date_to) ?></p>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>التاريخ</th>
<th>رقم القيد</th>
<th>البيان</th>
<th>الحساب</th>
<th>مدين</th>
<th>دائن</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $e_row): ?>
<tr>
<td><?= e($e_row['entry_date']) ?></td>
<td style="direction:ltr;text-align:right;font-size:12px;">
<a href="/accounting/journal-entries/<?= (int)$e_row['journal_entry_id'] ?>"><?= e($e_row['entry_number']) ?></a>
</td>
<td style="font-size:13px;"><?= e($e_row['entry_description'] ?? $e_row['description_ar'] ?? '') ?></td>
<td style="font-size:12px;"><?= e($e_row['account_code']) ?><?= e($e_row['account_name'] ?? '') ?></td>
<td style="direction:ltr;text-align:right;"><?= bccomp((string)$e_row['debit'], '0.00', 2) > 0 ? money($e_row['debit']) : '' ?></td>
<td style="direction:ltr;text-align:right;"><?= bccomp((string)$e_row['credit'], '0.00', 2) > 0 ? money($e_row['credit']) : '' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($entries)): ?>
<tr><td colspan="6" style="text-align:center;color:#6B7280;padding:30px;">لا توجد حركات لهذا العضو في الفترة المحددة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php elseif ($member_id > 0): ?>
<div class="card" style="padding:40px;text-align:center;color:#DC2626;">العضو غير موجود</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>ميزان المراجعة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:15px;">
<div style="padding:15px 20px;">
<form method="GET" action="/accounting/reports/trial-balance" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div>
<label class="form-label" style="font-size:12px;">من تاريخ</label>
<input type="date" name="date_from" class="form-input" value="<?= e($date_from) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">إلى تاريخ</label>
<input type="date" name="date_to" class="form-input" value="<?= e($date_to) ?>">
</div>
<div>
<label class="form-label" style="font-size:12px;">مركز التكلفة</label>
<select name="cost_center_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($cost_centers as $cc): ?>
<option value="<?= (int)$cc['id'] ?>" <?= (int)($filters['cost_center_id'] ?? 0) === (int)$cc['id'] ? 'selected' : '' ?>><?= e($cc['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label class="form-label" style="font-size:12px;">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int)$b['id'] ?>" <?= (int)($filters['branch_id'] ?? 0) === (int)$b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
</div>
<!-- Report -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">ميزان المراجعة</h3>
<span style="font-size:13px;color:#6B7280;">من <?= e($date_from) ?> إلى <?= e($date_to) ?></span>
</div>
<div class="table-responsive">
<table class="data-table" style="width:100%;">
<thead>
<tr style="background:#0D7377;color:white;">
<th style="padding:12px;">كود الحساب</th>
<th style="padding:12px;">اسم الحساب</th>
<th style="padding:12px;">النوع</th>
<th style="padding:12px;text-align:center;">مدين</th>
<th style="padding:12px;text-align:center;">دائن</th>
</tr>
</thead>
<tbody>
<?php foreach ($result['accounts'] as $acc): ?>
<?php
$typeLabel = match($acc['account_type']) {
'asset' => 'أصول',
'liability' => 'خصوم',
'equity' => 'حقوق ملكية',
'revenue' => 'إيرادات',
'expense' => 'مصروفات',
default => '',
};
?>
<tr>
<td style="direction:ltr;text-align:right;font-weight:600;font-size:13px;"><?= e($acc['account_code']) ?></td>
<td><?= e($acc['name_ar']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= $typeLabel ?></td>
<td style="direction:ltr;text-align:center;<?= bccomp($acc['tb_debit'], '0.00', 2) > 0 ? 'font-weight:600;' : 'color:#D1D5DB;' ?>"><?= money($acc['tb_debit']) ?></td>
<td style="direction:ltr;text-align:center;<?= bccomp($acc['tb_credit'], '0.00', 2) > 0 ? 'font-weight:600;' : 'color:#D1D5DB;' ?>"><?= money($acc['tb_credit']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr style="font-weight:700;font-size:16px;background:#F9FAFB;">
<td style="padding:15px;" colspan="3">الإجمالي</td>
<td style="direction:ltr;text-align:center;padding:15px;"><?= money($result['total_debit']) ?></td>
<td style="direction:ltr;text-align:center;padding:15px;"><?= money($result['total_credit']) ?></td>
</tr>
<tr>
<td colspan="5" style="text-align:center;padding:10px;">
<?php if ($result['is_balanced']): ?>
<span style="color:#059669;font-weight:700;">الميزان متوازن</span>
<?php else: ?>
<span style="color:#DC2626;font-weight:700;">تحذير: الميزان غير متوازن — الفرق: <?= money(bcsub($result['total_debit'], $result['total_credit'], 2)) ?></span>
<?php endif; ?>
</td>
</tr>
</tfoot>
</table>
</div>
</div>
<?php $__template->endSection(); ?>
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
use App\Core\EventBus;
use App\Modules\Accounting\Services\AccountingIntegrationService;
// ────────────────────────────────────────────────────────────
// Accounting Module — Permissions (28 total)
// ────────────────────────────────────────────────────────────
PermissionRegistry::register('accounting', [
// Dashboard & Reports
'accounting.reports.view' => ['ar' => 'لوحة تحكم المحاسبة', 'en' => 'Accounting Dashboard'],
'accounting.reports.trial_balance' => ['ar' => 'ميزان المراجعة', 'en' => 'Trial Balance'],
'accounting.reports.general_ledger' => ['ar' => 'دفتر الأستاذ العام', 'en' => 'General Ledger'],
'accounting.reports.income_statement' => ['ar' => 'قائمة الدخل', 'en' => 'Income Statement'],
'accounting.reports.balance_sheet' => ['ar' => 'الميزانية العمومية', 'en' => 'Balance Sheet'],
'accounting.reports.consolidated' => ['ar' => 'ميزانية موحدة', 'en' => 'Consolidated Balance Sheet'],
'accounting.reports.ar' => ['ar' => 'تقرير المدينين', 'en' => 'Accounts Receivable Report'],
'accounting.reports.ap' => ['ar' => 'تقرير الدائنين', 'en' => 'Accounts Payable Report'],
'accounting.reports.member_statement' => ['ar' => 'كشف حساب عضو', 'en' => 'Member Statement'],
// Fiscal Year
'accounting.fiscal_year.view' => ['ar' => 'عرض السنوات المالية', 'en' => 'View Fiscal Years'],
'accounting.fiscal_year.manage' => ['ar' => 'إدارة السنوات المالية', 'en' => 'Manage Fiscal Years'],
'accounting.fiscal_year.close' => ['ar' => 'إقفال السنة المالية', 'en' => 'Close Fiscal Year'],
// Chart of Accounts
'accounting.coa.view' => ['ar' => 'عرض دليل الحسابات', 'en' => 'View Chart of Accounts'],
'accounting.coa.manage' => ['ar' => 'إدارة دليل الحسابات', 'en' => 'Manage Chart of Accounts'],
// Cost Centers
'accounting.cost_center.view' => ['ar' => 'عرض مراكز التكلفة', 'en' => 'View Cost Centers'],
'accounting.cost_center.manage' => ['ar' => 'إدارة مراكز التكلفة', 'en' => 'Manage Cost Centers'],
// Bank Accounts
'accounting.bank_account.view' => ['ar' => 'عرض الحسابات البنكية', 'en' => 'View Bank Accounts'],
'accounting.bank_account.manage' => ['ar' => 'إدارة الحسابات البنكية', 'en' => 'Manage Bank Accounts'],
// Journal Entries
'accounting.journal.view' => ['ar' => 'عرض قيود اليومية', 'en' => 'View Journal Entries'],
'accounting.journal.create' => ['ar' => 'إنشاء قيد يومية', 'en' => 'Create Journal Entry'],
'accounting.journal.post' => ['ar' => 'ترحيل قيود اليومية', 'en' => 'Post Journal Entries'],
'accounting.journal.reverse' => ['ar' => 'عكس قيود اليومية', 'en' => 'Reverse Journal Entries'],
// Bank Reconciliation
'accounting.bank_recon.view' => ['ar' => 'عرض المطابقات البنكية', 'en' => 'View Bank Reconciliations'],
'accounting.bank_recon.manage' => ['ar' => 'إدارة المطابقات البنكية', 'en' => 'Manage Bank Reconciliations'],
// Period Closing
'accounting.period.view' => ['ar' => 'عرض إقفال الفترات', 'en' => 'View Period Closings'],
'accounting.period.close' => ['ar' => 'إقفال فترة', 'en' => 'Close Period'],
'accounting.period.reopen' => ['ar' => 'إعادة فتح فترة مغلقة', 'en' => 'Reopen Closed Period'],
]);
// ────────────────────────────────────────────────────────────
// Accounting Module — Sidebar Menu
// ────────────────────────────────────────────────────────────
MenuRegistry::register('accounting', [
'label_ar' => 'المحاسبة والدفتر العام',
'label_en' => 'Accounting & GL',
'icon' => 'calculator',
'route' => '/accounting',
'permission' => 'accounting.reports.view',
'parent' => null,
'order' => 400,
'children' => [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/accounting', 'permission' => 'accounting.reports.view', 'order' => 1],
['label_ar' => 'دليل الحسابات', 'label_en' => 'Chart of Accounts', 'route' => '/accounting/chart-of-accounts', 'permission' => 'accounting.coa.view', 'order' => 2],
['label_ar' => 'قيود اليومية', 'label_en' => 'Journal Entries', 'route' => '/accounting/journal-entries', 'permission' => 'accounting.journal.view', 'order' => 3],
['label_ar' => 'السنوات المالية', 'label_en' => 'Fiscal Years', 'route' => '/accounting/fiscal-years', 'permission' => 'accounting.fiscal_year.view', 'order' => 4],
['label_ar' => 'مراكز التكلفة', 'label_en' => 'Cost Centers', 'route' => '/accounting/cost-centers', 'permission' => 'accounting.cost_center.view', 'order' => 5],
['label_ar' => 'الحسابات البنكية', 'label_en' => 'Bank Accounts', 'route' => '/accounting/bank-accounts', 'permission' => 'accounting.bank_account.view', 'order' => 6],
['label_ar' => 'المطابقة البنكية', 'label_en' => 'Bank Reconciliation', 'route' => '/accounting/bank-reconciliation', 'permission' => 'accounting.bank_recon.view', 'order' => 7],
['label_ar' => 'إقفال الفترات', 'label_en' => 'Period Closing', 'route' => '/accounting/period-closing', 'permission' => 'accounting.period.view', 'order' => 8],
['label_ar' => 'ميزان المراجعة', 'label_en' => 'Trial Balance', 'route' => '/accounting/reports/trial-balance', 'permission' => 'accounting.reports.trial_balance', 'order' => 9],
['label_ar' => 'دفتر الأستاذ', 'label_en' => 'General Ledger', 'route' => '/accounting/reports/general-ledger', 'permission' => 'accounting.reports.general_ledger', 'order' => 10],
['label_ar' => 'قائمة الدخل', 'label_en' => 'Income Statement', 'route' => '/accounting/reports/income-statement', 'permission' => 'accounting.reports.income_statement','order' => 11],
['label_ar' => 'الميزانية العمومية', 'label_en' => 'Balance Sheet', 'route' => '/accounting/reports/balance-sheet', 'permission' => 'accounting.reports.balance_sheet', 'order' => 12],
['label_ar' => 'ميزانية موحدة', 'label_en' => 'Consolidated BS', 'route' => '/accounting/reports/consolidated-balance-sheet', 'permission' => 'accounting.reports.consolidated', 'order' => 13],
['label_ar' => 'المدينون (AR)', 'label_en' => 'Accounts Receivable', 'route' => '/accounting/reports/accounts-receivable', 'permission' => 'accounting.reports.ar', 'order' => 14],
['label_ar' => 'الدائنون (AP)', 'label_en' => 'Accounts Payable', 'route' => '/accounting/reports/accounts-payable', 'permission' => 'accounting.reports.ap', 'order' => 15],
['label_ar' => 'كشف حساب عضو', 'label_en' => 'Member Statement', 'route' => '/accounting/reports/member-statement', 'permission' => 'accounting.reports.member_statement','order' => 16],
],
]);
// ────────────────────────────────────────────────────────────
// Accounting Module — Event Listeners (Auto-Posting)
// ────────────────────────────────────────────────────────────
// ── Payments ─────────────────────────────────────────────────
// When a payment is completed, auto-post journal entry: Dr. Cash/Bank, Cr. Revenue
EventBus::listen('payment.completed', function (array $data): void {
try {
AccountingIntegrationService::onPaymentCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (payment.completed): ' . $e->getMessage());
}
}, 50); // Priority 50 — runs after Payments module's own listener (100)
// When a payment is voided, reverse the journal entry
EventBus::listen('payment.voided', function (array $data): void {
try {
AccountingIntegrationService::onPaymentVoided($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-reverse failed (payment.voided): ' . $e->getMessage());
}
}, 50);
// ── Sales ────────────────────────────────────────────────────
// When a sale is completed, auto-post COGS entry
EventBus::listen('sale.completed', function (array $data): void {
try {
AccountingIntegrationService::onSaleCompleted($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (sale.completed): ' . $e->getMessage());
}
}, 50);
// When a sale is voided, reverse COGS entry
EventBus::listen('sale.voided', function (array $data): void {
try {
AccountingIntegrationService::onSaleVoided($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-reverse failed (sale.voided): ' . $e->getMessage());
}
}, 50);
// ── HR Payroll ───────────────────────────────────────────────
// When payroll is paid, auto-post salary journal
EventBus::listen('hr.payroll.paid', function (array $data): void {
try {
AccountingIntegrationService::onPayrollPaid($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (hr.payroll.paid): ' . $e->getMessage());
}
}, 50);
// ── Subscriptions ────────────────────────────────────────────
// When a subscription is paid, update AR
EventBus::listen('subscription.paid', function (array $data): void {
try {
AccountingIntegrationService::onSubscriptionPaid($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (subscription.paid): ' . $e->getMessage());
}
}, 50);
// ── Fines ────────────────────────────────────────────────────
// When a fine is imposed, create AR entry and journal
EventBus::listen('fine.imposed', function (array $data): void {
try {
AccountingIntegrationService::onFineImposed($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (fine.imposed): ' . $e->getMessage());
}
}, 50);
// When a fine is collected, update AR
EventBus::listen('fine.collected', function (array $data): void {
try {
AccountingIntegrationService::onFineCollected($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (fine.collected): ' . $e->getMessage());
}
}, 50);
// ── Installments ─────────────────────────────────────────────
// When an installment plan is created, create AR entry
EventBus::listen('installment.plan_created', function (array $data): void {
try {
AccountingIntegrationService::onInstallmentPlanCreated($data);
} catch (\Throwable $e) {
\App\Core\Logger::error('Accounting auto-post failed (installment.plan_created): ' . $e->getMessage());
}
}, 50);
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `fiscal_years` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name_ar` VARCHAR(100) NOT NULL COMMENT 'e.g. السنة المالية 2025/2026',
`name_en` VARCHAR(100) NOT NULL COMMENT 'e.g. FY 2025/2026',
`start_date` DATE NOT NULL,
`end_date` DATE NOT NULL,
`status` ENUM('open','closing','closed') NOT NULL DEFAULT 'open',
`is_current` TINYINT(1) NOT NULL DEFAULT 0,
`closed_at` DATETIME NULL,
`closed_by` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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,
INDEX `idx_fiscal_years_status` (`status`),
INDEX `idx_fiscal_years_current` (`is_current`),
INDEX `idx_fiscal_years_dates` (`start_date`, `end_date`),
CONSTRAINT `fk_fiscal_years_closed_by` FOREIGN KEY (`closed_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_fiscal_years_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_fiscal_years_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `fiscal_years`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `chart_of_accounts` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`account_code` VARCHAR(20) NOT NULL COMMENT 'Hierarchical code e.g. 1101, 110101',
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NOT NULL,
`account_type` ENUM('asset','liability','equity','revenue','expense') NOT NULL,
`account_nature` ENUM('debit','credit') NOT NULL,
`parent_id` BIGINT UNSIGNED NULL COMMENT 'Self-referencing for tree structure',
`level` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '1=category, 2=group, 3=sub-group, 4=detail',
`is_header` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=parent only, cannot post to',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`is_system` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'System accounts cannot be deleted',
`description_ar` TEXT NULL,
`description_en` TEXT NULL,
`opening_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`current_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`cost_center_id` BIGINT UNSIGNED NULL COMMENT 'Default cost center',
`is_bank_account` TINYINT(1) NOT NULL DEFAULT 0,
`bank_account_id` BIGINT UNSIGNED NULL COMMENT 'Links to bank_accounts table',
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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 INDEX `idx_coa_account_code` (`account_code`),
INDEX `idx_coa_account_type` (`account_type`),
INDEX `idx_coa_parent_id` (`parent_id`),
INDEX `idx_coa_level` (`level`),
INDEX `idx_coa_is_active` (`is_active`),
INDEX `idx_coa_is_header` (`is_header`),
CONSTRAINT `fk_coa_parent` FOREIGN KEY (`parent_id`) REFERENCES `chart_of_accounts`(`id`),
CONSTRAINT `fk_coa_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_coa_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `chart_of_accounts`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `cost_centers` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`code` VARCHAR(20) NOT NULL,
`name_ar` VARCHAR(150) NOT NULL,
`name_en` VARCHAR(150) NOT NULL,
`type` ENUM('cost_center','profit_center') NOT NULL DEFAULT 'cost_center',
`branch_id` BIGINT UNSIGNED NULL COMMENT 'Linked branch for branch-based accounting',
`parent_id` BIGINT UNSIGNED NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`description` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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 INDEX `idx_cc_code` (`code`),
INDEX `idx_cc_branch_id` (`branch_id`),
INDEX `idx_cc_parent_id` (`parent_id`),
INDEX `idx_cc_type` (`type`),
CONSTRAINT `fk_cc_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_cc_parent` FOREIGN KEY (`parent_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_cc_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_cc_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `cost_centers`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `bank_accounts` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`account_name_ar` VARCHAR(200) NOT NULL,
`account_name_en` VARCHAR(200) NOT NULL,
`bank_name_ar` VARCHAR(200) NOT NULL,
`bank_name_en` VARCHAR(200) NULL,
`account_number` VARCHAR(50) NOT NULL,
`iban` VARCHAR(34) NULL,
`swift_code` VARCHAR(11) NULL,
`branch_name` VARCHAR(150) NULL,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`gl_account_id` BIGINT UNSIGNED NULL COMMENT 'Linked chart_of_accounts entry',
`opening_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`current_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`is_default` TINYINT(1) NOT NULL DEFAULT 0,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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 INDEX `idx_ba_account_number` (`account_number`),
INDEX `idx_ba_gl_account` (`gl_account_id`),
INDEX `idx_ba_is_active` (`is_active`),
CONSTRAINT `fk_ba_gl_account` FOREIGN KEY (`gl_account_id`) REFERENCES `chart_of_accounts`(`id`),
CONSTRAINT `fk_ba_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_ba_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `bank_accounts`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `journal_entries` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`entry_number` VARCHAR(30) NOT NULL COMMENT 'JE-2026-000001',
`fiscal_year_id` BIGINT UNSIGNED NOT NULL,
`entry_date` DATE NOT NULL,
`reference_type` VARCHAR(50) NULL COMMENT 'payment, sale, payroll, subscription, fine, manual, opening, closing',
`reference_id` BIGINT UNSIGNED NULL COMMENT 'ID of source record',
`reference_number` VARCHAR(50) NULL COMMENT 'Source document number',
`description_ar` VARCHAR(500) NOT NULL,
`description_en` VARCHAR(500) NULL,
`total_debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`total_credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`status` ENUM('draft','posted','reversed') NOT NULL DEFAULT 'draft',
`posted_at` DATETIME NULL,
`posted_by` BIGINT UNSIGNED NULL,
`reversed_entry_id` BIGINT UNSIGNED NULL COMMENT 'If this reverses another entry',
`reversal_of_id` BIGINT UNSIGNED NULL COMMENT 'If reversed, points to reversal entry',
`is_auto_generated` TINYINT(1) NOT NULL DEFAULT 0,
`source_module` VARCHAR(50) NULL COMMENT 'payments, sales, hr, subscriptions, fines, inventory',
`branch_id` BIGINT UNSIGNED NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`period_closed` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Locked after period closing',
`attachments` JSON NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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 INDEX `idx_je_entry_number` (`entry_number`),
INDEX `idx_je_fiscal_year` (`fiscal_year_id`),
INDEX `idx_je_entry_date` (`entry_date`),
INDEX `idx_je_status` (`status`),
INDEX `idx_je_reference` (`reference_type`, `reference_id`),
INDEX `idx_je_source_module` (`source_module`),
INDEX `idx_je_branch` (`branch_id`),
INDEX `idx_je_cost_center` (`cost_center_id`),
CONSTRAINT `fk_je_fiscal_year` FOREIGN KEY (`fiscal_year_id`) REFERENCES `fiscal_years`(`id`),
CONSTRAINT `fk_je_posted_by` FOREIGN KEY (`posted_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_je_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_je_cost_center` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_je_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_je_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `journal_entries`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `journal_entry_lines` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`journal_entry_id` BIGINT UNSIGNED NOT NULL,
`line_number` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
`account_id` BIGINT UNSIGNED NOT NULL COMMENT 'chart_of_accounts.id',
`description_ar` VARCHAR(300) NULL,
`description_en` VARCHAR(300) NULL,
`debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`cost_center_id` BIGINT UNSIGNED NULL COMMENT 'Line-level cost center override',
`branch_id` BIGINT UNSIGNED NULL COMMENT 'Line-level branch override',
`member_id` BIGINT UNSIGNED NULL COMMENT 'For member-related AR/AP tracking',
`employee_id` BIGINT UNSIGNED NULL COMMENT 'For employee-related entries',
`supplier_id` BIGINT UNSIGNED NULL COMMENT 'For supplier-related AP tracking',
`reference_type` VARCHAR(50) NULL COMMENT 'Sub-reference for line detail',
`reference_id` BIGINT UNSIGNED NULL,
`notes` VARCHAR(300) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_jel_journal_entry` (`journal_entry_id`),
INDEX `idx_jel_account` (`account_id`),
INDEX `idx_jel_cost_center` (`cost_center_id`),
INDEX `idx_jel_branch` (`branch_id`),
INDEX `idx_jel_member` (`member_id`),
INDEX `idx_jel_employee` (`employee_id`),
INDEX `idx_jel_supplier` (`supplier_id`),
CONSTRAINT `fk_jel_journal_entry` FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_jel_account` FOREIGN KEY (`account_id`) REFERENCES `chart_of_accounts`(`id`),
CONSTRAINT `fk_jel_cost_center` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_jel_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_jel_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_jel_employee` FOREIGN KEY (`employee_id`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_jel_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `journal_entry_lines`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `account_balances` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`account_id` BIGINT UNSIGNED NOT NULL,
`fiscal_year_id` BIGINT UNSIGNED NOT NULL,
`period` VARCHAR(7) NOT NULL COMMENT 'YYYY-MM format',
`opening_debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`opening_credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`period_debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`period_credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`closing_debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`closing_credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`is_closed` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX `idx_ab_unique` (`account_id`, `fiscal_year_id`, `period`, `cost_center_id`, `branch_id`),
INDEX `idx_ab_fiscal_year` (`fiscal_year_id`),
INDEX `idx_ab_period` (`period`),
INDEX `idx_ab_cost_center` (`cost_center_id`),
INDEX `idx_ab_branch` (`branch_id`),
CONSTRAINT `fk_ab_account` FOREIGN KEY (`account_id`) REFERENCES `chart_of_accounts`(`id`),
CONSTRAINT `fk_ab_fiscal_year` FOREIGN KEY (`fiscal_year_id`) REFERENCES `fiscal_years`(`id`),
CONSTRAINT `fk_ab_cost_center` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_ab_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `account_balances`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `bank_reconciliations` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`bank_account_id` BIGINT UNSIGNED NOT NULL,
`reconciliation_date` DATE NOT NULL,
`statement_date` DATE NOT NULL,
`statement_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`book_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`adjusted_balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`difference` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`status` ENUM('draft','in_progress','completed','approved') NOT NULL DEFAULT 'draft',
`approved_at` DATETIME NULL,
`approved_by` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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,
INDEX `idx_br_bank_account` (`bank_account_id`),
INDEX `idx_br_status` (`status`),
INDEX `idx_br_recon_date` (`reconciliation_date`),
CONSTRAINT `fk_br_bank_account` FOREIGN KEY (`bank_account_id`) REFERENCES `bank_accounts`(`id`),
CONSTRAINT `fk_br_approved_by` FOREIGN KEY (`approved_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_br_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_br_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `bank_reconciliations`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `bank_reconciliation_items` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`reconciliation_id` BIGINT UNSIGNED NOT NULL,
`item_type` ENUM('outstanding_check','deposit_in_transit','bank_charge','bank_interest','book_error','bank_error','other') NOT NULL,
`description_ar` VARCHAR(300) NOT NULL,
`description_en` VARCHAR(300) NULL,
`amount` DECIMAL(18,2) NOT NULL,
`transaction_date` DATE NULL,
`reference_number` VARCHAR(50) NULL,
`journal_entry_id` BIGINT UNSIGNED NULL COMMENT 'Adjusting entry if created',
`is_cleared` TINYINT(1) NOT NULL DEFAULT 0,
`cleared_date` DATE NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_bri_reconciliation` (`reconciliation_id`),
INDEX `idx_bri_type` (`item_type`),
INDEX `idx_bri_cleared` (`is_cleared`),
CONSTRAINT `fk_bri_reconciliation` FOREIGN KEY (`reconciliation_id`) REFERENCES `bank_reconciliations`(`id`) ON DELETE CASCADE,
CONSTRAINT `fk_bri_journal_entry` FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `bank_reconciliation_items`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `accounts_payable` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`supplier_id` BIGINT UNSIGNED NOT NULL,
`invoice_number` VARCHAR(50) NOT NULL,
`invoice_date` DATE NOT NULL,
`due_date` DATE NOT NULL,
`description_ar` VARCHAR(300) NOT NULL,
`description_en` VARCHAR(300) NULL,
`total_amount` DECIMAL(18,2) NOT NULL,
`paid_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`status` ENUM('pending','partial','paid','overdue','cancelled') NOT NULL DEFAULT 'pending',
`journal_entry_id` BIGINT UNSIGNED NULL COMMENT 'Original accrual entry',
`payment_entry_id` BIGINT UNSIGNED NULL COMMENT 'Payment journal entry',
`purchase_order_id` BIGINT UNSIGNED NULL,
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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,
INDEX `idx_ap_supplier` (`supplier_id`),
INDEX `idx_ap_status` (`status`),
INDEX `idx_ap_due_date` (`due_date`),
INDEX `idx_ap_invoice_date` (`invoice_date`),
INDEX `idx_ap_cost_center` (`cost_center_id`),
INDEX `idx_ap_branch` (`branch_id`),
CONSTRAINT `fk_ap_supplier` FOREIGN KEY (`supplier_id`) REFERENCES `suppliers`(`id`),
CONSTRAINT `fk_ap_journal_entry` FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`),
CONSTRAINT `fk_ap_payment_entry` FOREIGN KEY (`payment_entry_id`) REFERENCES `journal_entries`(`id`),
CONSTRAINT `fk_ap_purchase_order` FOREIGN KEY (`purchase_order_id`) REFERENCES `purchase_orders`(`id`),
CONSTRAINT `fk_ap_cost_center` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_ap_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_ap_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `accounts_payable`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `accounts_receivable` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NOT NULL,
`document_type` VARCHAR(50) NOT NULL COMMENT 'installment, subscription, fine, membership_fee, other',
`document_id` BIGINT UNSIGNED NULL COMMENT 'ID of source: installment_plans.id, subscriptions.id, fines.id',
`document_number` VARCHAR(50) NULL,
`document_date` DATE NOT NULL,
`due_date` DATE NOT NULL,
`description_ar` VARCHAR(300) NOT NULL,
`description_en` VARCHAR(300) NULL,
`total_amount` DECIMAL(18,2) NOT NULL,
`paid_amount` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`balance` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`currency` VARCHAR(3) NOT NULL DEFAULT 'EGP',
`status` ENUM('pending','partial','paid','overdue','written_off','cancelled') NOT NULL DEFAULT 'pending',
`journal_entry_id` BIGINT UNSIGNED NULL COMMENT 'Original receivable entry',
`payment_entry_id` BIGINT UNSIGNED NULL COMMENT 'Payment/settlement entry',
`cost_center_id` BIGINT UNSIGNED NULL,
`branch_id` BIGINT UNSIGNED NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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,
INDEX `idx_ar_member` (`member_id`),
INDEX `idx_ar_status` (`status`),
INDEX `idx_ar_due_date` (`due_date`),
INDEX `idx_ar_document` (`document_type`, `document_id`),
INDEX `idx_ar_cost_center` (`cost_center_id`),
INDEX `idx_ar_branch` (`branch_id`),
CONSTRAINT `fk_ar_member` FOREIGN KEY (`member_id`) REFERENCES `members`(`id`),
CONSTRAINT `fk_ar_journal_entry` FOREIGN KEY (`journal_entry_id`) REFERENCES `journal_entries`(`id`),
CONSTRAINT `fk_ar_payment_entry` FOREIGN KEY (`payment_entry_id`) REFERENCES `journal_entries`(`id`),
CONSTRAINT `fk_ar_cost_center` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_centers`(`id`),
CONSTRAINT `fk_ar_branch` FOREIGN KEY (`branch_id`) REFERENCES `branches`(`id`),
CONSTRAINT `fk_ar_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `accounts_receivable`",
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `period_closings` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`fiscal_year_id` BIGINT UNSIGNED NOT NULL,
`period` VARCHAR(7) NOT NULL COMMENT 'YYYY-MM',
`period_start` DATE NOT NULL,
`period_end` DATE NOT NULL,
`closing_type` ENUM('monthly','yearly') NOT NULL DEFAULT 'monthly',
`status` ENUM('open','closing','closed','reopened') NOT NULL DEFAULT 'open',
`total_debit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`total_credit` DECIMAL(18,2) NOT NULL DEFAULT 0.00,
`journal_count` INT UNSIGNED NOT NULL DEFAULT 0,
`closing_entry_id` BIGINT UNSIGNED NULL COMMENT 'Year-end closing journal entry',
`closed_at` DATETIME NULL,
`closed_by` BIGINT UNSIGNED NULL,
`reopened_at` DATETIME NULL,
`reopened_by` BIGINT UNSIGNED NULL,
`reopen_reason` TEXT NULL,
`notes` TEXT NULL,
`is_archived` TINYINT(1) NOT NULL DEFAULT 0,
`archived_at` DATETIME NULL,
`archived_by` BIGINT UNSIGNED 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 INDEX `idx_pc_unique_period` (`fiscal_year_id`, `period`),
INDEX `idx_pc_status` (`status`),
INDEX `idx_pc_closing_type` (`closing_type`),
CONSTRAINT `fk_pc_fiscal_year` FOREIGN KEY (`fiscal_year_id`) REFERENCES `fiscal_years`(`id`),
CONSTRAINT `fk_pc_closing_entry` FOREIGN KEY (`closing_entry_id`) REFERENCES `journal_entries`(`id`),
CONSTRAINT `fk_pc_closed_by` FOREIGN KEY (`closed_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_pc_reopened_by` FOREIGN KEY (`reopened_by`) REFERENCES `employees`(`id`),
CONSTRAINT `fk_pc_created_by` FOREIGN KEY (`created_by`) REFERENCES `employees`(`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `period_closings`",
];
<?php
declare(strict_types=1);
use App\Core\Database;
/**
* Egyptian Standard Chart of Accounts - tailored for Club/Membership ERP.
*
* Structure: 4-digit codes
* 1xxx = Assets
* 2xxx = Liabilities
* 3xxx = Equity
* 4xxx = Revenue
* 5xxx = Expenses
*
* Run: php cli.php seed
*/
return function (Database $db): void {
$accounts = [
// ══════════════════════════════════════════════════════════
// 1. ASSETS
// ══════════════════════════════════════════════════════════
['account_code' => '1000', 'name_ar' => 'الأصول', 'name_en' => 'Assets', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => null, 'level' => 1, 'is_header' => 1, 'is_system' => 1],
// Current Assets
['account_code' => '1100', 'name_ar' => 'أصول متداولة', 'name_en' => 'Current Assets', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '1101', 'name_ar' => 'نقدية بالصندوق', 'name_en' => 'Cash on Hand', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '1102', 'name_ar' => 'نقدية بالبنك', 'name_en' => 'Cash at Bank', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '1103', 'name_ar' => 'حسابات مدينة (مدينون)', 'name_en' => 'Accounts Receivable', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '1104', 'name_ar' => 'مخزون', 'name_en' => 'Inventory', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '1105', 'name_ar' => 'مصروفات مقدمة', 'name_en' => 'Prepaid Expenses', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1106', 'name_ar' => 'شيكات تحت التحصيل', 'name_en' => 'Checks Under Collection', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1107', 'name_ar' => 'سلف ومقدمات موظفين', 'name_en' => 'Employee Advances', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1108', 'name_ar' => 'أوراق قبض', 'name_en' => 'Notes Receivable', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
// Fixed Assets
['account_code' => '1200', 'name_ar' => 'أصول ثابتة', 'name_en' => 'Fixed Assets', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '1201', 'name_ar' => 'أراضي', 'name_en' => 'Land', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1202', 'name_ar' => 'مباني ومنشآت', 'name_en' => 'Buildings', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1203', 'name_ar' => 'أثاث وتجهيزات', 'name_en' => 'Furniture & Fixtures', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1204', 'name_ar' => 'معدات وأجهزة', 'name_en' => 'Equipment', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1205', 'name_ar' => 'سيارات ووسائل نقل', 'name_en' => 'Vehicles', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1206', 'name_ar' => 'أجهزة حاسب آلي', 'name_en' => 'Computer Equipment', 'account_type' => 'asset', 'account_nature' => 'debit', 'parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '1290', 'name_ar' => 'مجمع الإهلاك', 'name_en' => 'Accumulated Depreciation', 'account_type' => 'asset', 'account_nature' => 'credit','parent_code' => '1200', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
// ══════════════════════════════════════════════════════════
// 2. LIABILITIES
// ══════════════════════════════════════════════════════════
['account_code' => '2000', 'name_ar' => 'الخصوم', 'name_en' => 'Liabilities', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => null, 'level' => 1, 'is_header' => 1, 'is_system' => 1],
// Current Liabilities
['account_code' => '2100', 'name_ar' => 'خصوم متداولة', 'name_en' => 'Current Liabilities', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '2101', 'name_ar' => 'حسابات دائنة (دائنون)', 'name_en' => 'Accounts Payable', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '2102', 'name_ar' => 'مصروفات مستحقة', 'name_en' => 'Accrued Expenses', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '2103', 'name_ar' => 'ضرائب مستحقة', 'name_en' => 'Tax Payable', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '2104', 'name_ar' => 'تأمينات اجتماعية مستحقة', 'name_en' => 'Insurance Payable', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '2105', 'name_ar' => 'إيرادات مؤجلة', 'name_en' => 'Deferred Revenue', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '2106', 'name_ar' => 'أوراق دفع', 'name_en' => 'Notes Payable', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '2107', 'name_ar' => 'تأمينات عضويات مستحقة', 'name_en' => 'Membership Deposits', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
// Long-term Liabilities
['account_code' => '2200', 'name_ar' => 'خصوم طويلة الأجل', 'name_en' => 'Long-term Liabilities', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2000', 'level' => 2, 'is_header' => 1, 'is_system' => 0],
['account_code' => '2201', 'name_ar' => 'قروض طويلة الأجل', 'name_en' => 'Long-term Loans', 'account_type' => 'liability', 'account_nature' => 'credit', 'parent_code' => '2200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
// ══════════════════════════════════════════════════════════
// 3. EQUITY
// ══════════════════════════════════════════════════════════
['account_code' => '3000', 'name_ar' => 'حقوق الملكية', 'name_en' => 'Equity', 'account_type' => 'equity', 'account_nature' => 'credit', 'parent_code' => null, 'level' => 1, 'is_header' => 1, 'is_system' => 1],
['account_code' => '3101', 'name_ar' => 'رأس المال', 'name_en' => 'Capital', 'account_type' => 'equity', 'account_nature' => 'credit', 'parent_code' => '3000', 'level' => 2, 'is_header' => 0, 'is_system' => 1],
['account_code' => '3102', 'name_ar' => 'أرباح محتجزة', 'name_en' => 'Retained Earnings', 'account_type' => 'equity', 'account_nature' => 'credit', 'parent_code' => '3000', 'level' => 2, 'is_header' => 0, 'is_system' => 1],
['account_code' => '3103', 'name_ar' => 'احتياطي قانوني', 'name_en' => 'Legal Reserve', 'account_type' => 'equity', 'account_nature' => 'credit', 'parent_code' => '3000', 'level' => 2, 'is_header' => 0, 'is_system' => 0],
['account_code' => '3104', 'name_ar' => 'احتياطي عام', 'name_en' => 'General Reserve', 'account_type' => 'equity', 'account_nature' => 'credit', 'parent_code' => '3000', 'level' => 2, 'is_header' => 0, 'is_system' => 0],
// ══════════════════════════════════════════════════════════
// 4. REVENUE
// ══════════════════════════════════════════════════════════
['account_code' => '4000', 'name_ar' => 'الإيرادات', 'name_en' => 'Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => null, 'level' => 1, 'is_header' => 1, 'is_system' => 1],
// Operating Revenue
['account_code' => '4100', 'name_ar' => 'إيرادات تشغيلية', 'name_en' => 'Operating Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '4101', 'name_ar' => 'إيرادات عضوية', 'name_en' => 'Membership Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4102', 'name_ar' => 'إيرادات اشتراكات', 'name_en' => 'Subscription Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4103', 'name_ar' => 'إيرادات مبيعات', 'name_en' => 'Sales Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4104', 'name_ar' => 'إيرادات غرامات', 'name_en' => 'Fine Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4105', 'name_ar' => 'إيرادات خدمات', 'name_en' => 'Service Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4106', 'name_ar' => 'إيرادات إيجارات', 'name_en' => 'Rental Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '4107', 'name_ar' => 'إيرادات حجوزات ومرافق', 'name_en' => 'Facility Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '4108', 'name_ar' => 'إيرادات أكاديميات', 'name_en' => 'Academy Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '4109', 'name_ar' => 'إيرادات كارنيهات', 'name_en' => 'Carnet Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
// Other Revenue
['account_code' => '4300', 'name_ar' => 'إيرادات أخرى', 'name_en' => 'Other Revenue', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4000', 'level' => 2, 'is_header' => 1, 'is_system' => 0],
['account_code' => '4301', 'name_ar' => 'فوائد بنكية دائنة', 'name_en' => 'Bank Interest Income', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4300', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '4302', 'name_ar' => 'إيرادات متنوعة', 'name_en' => 'Miscellaneous Income', 'account_type' => 'revenue', 'account_nature' => 'credit', 'parent_code' => '4300', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
// ══════════════════════════════════════════════════════════
// 5. EXPENSES
// ══════════════════════════════════════════════════════════
['account_code' => '5000', 'name_ar' => 'المصروفات', 'name_en' => 'Expenses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => null, 'level' => 1, 'is_header' => 1, 'is_system' => 1],
// Personnel Expenses
['account_code' => '5100', 'name_ar' => 'مصروفات الموظفين', 'name_en' => 'Personnel Expenses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '5101', 'name_ar' => 'مصروفات رواتب وأجور', 'name_en' => 'Salaries & Wages', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '5102', 'name_ar' => 'حصة صاحب العمل تأمينات', 'name_en' => 'Employer Insurance', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5100', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '5103', 'name_ar' => 'بدلات وحوافز', 'name_en' => 'Allowances & Bonuses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5104', 'name_ar' => 'مصروفات تدريب', 'name_en' => 'Training Expenses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5105', 'name_ar' => 'مكافأة نهاية خدمة', 'name_en' => 'End of Service Benefits', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5100', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5106', 'name_ar' => 'تكلفة البضاعة المباعة', 'name_en' => 'Cost of Goods Sold', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5000', 'level' => 2, 'is_header' => 0, 'is_system' => 1],
// Operating Expenses
['account_code' => '5200', 'name_ar' => 'مصروفات تشغيلية', 'name_en' => 'Operating Expenses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5000', 'level' => 2, 'is_header' => 1, 'is_system' => 1],
['account_code' => '5201', 'name_ar' => 'مصاريف بنكية', 'name_en' => 'Bank Charges', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '5202', 'name_ar' => 'مصروفات إيجار', 'name_en' => 'Rent Expense', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5203', 'name_ar' => 'مصروفات مرافق (كهرباء/مياه/غاز)', 'name_en' => 'Utilities', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5204', 'name_ar' => 'مصروفات صيانة', 'name_en' => 'Maintenance Expense', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5205', 'name_ar' => 'مصروفات إهلاك', 'name_en' => 'Depreciation Expense', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 1],
['account_code' => '5206', 'name_ar' => 'مصروفات نظافة', 'name_en' => 'Cleaning Expense', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5207', 'name_ar' => 'مصروفات أمن وحراسة', 'name_en' => 'Security Expense', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5208', 'name_ar' => 'مصروفات تأمين (ممتلكات)', 'name_en' => 'Property Insurance', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5209', 'name_ar' => 'مصروفات اتصالات وإنترنت', 'name_en' => 'Communications', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5210', 'name_ar' => 'مصروفات مطبوعات وقرطاسية', 'name_en' => 'Printing & Stationery', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5211', 'name_ar' => 'مصروفات ضيافة', 'name_en' => 'Hospitality', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5212', 'name_ar' => 'مصروفات نقل ومواصلات', 'name_en' => 'Transportation', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5213', 'name_ar' => 'مصروفات إعلان وتسويق', 'name_en' => 'Marketing & Advertising', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5214', 'name_ar' => 'مصروفات رسوم حكومية', 'name_en' => 'Government Fees', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5215', 'name_ar' => 'مصروفات استشارات قانونية', 'name_en' => 'Legal & Professional Fees', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
['account_code' => '5299', 'name_ar' => 'مصروفات متنوعة', 'name_en' => 'Miscellaneous Expenses', 'account_type' => 'expense', 'account_nature' => 'debit', 'parent_code' => '5200', 'level' => 3, 'is_header' => 0, 'is_system' => 0],
];
// Build code => id map as we insert, so children can resolve parent_id
$codeToId = [];
$now = date('Y-m-d H:i:s');
foreach ($accounts as $acct) {
// Skip if already seeded
$existing = $db->selectOne(
"SELECT id FROM chart_of_accounts WHERE account_code = ?",
[$acct['account_code']]
);
if ($existing) {
$codeToId[$acct['account_code']] = (int) $existing['id'];
continue;
}
// Resolve parent_code to parent_id
$parentId = null;
$parentCode = $acct['parent_code'] ?? null;
if ($parentCode !== null && isset($codeToId[$parentCode])) {
$parentId = $codeToId[$parentCode];
}
unset($acct['parent_code']);
$insertId = $db->insert('chart_of_accounts', array_merge($acct, [
'parent_id' => $parentId,
'opening_balance' => '0.00',
'current_balance' => '0.00',
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]));
$codeToId[$acct['account_code']] = $insertId;
}
};
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