diff --git a/db/migrations/20260503_seed_chart_of_accounts.php b/db/migrations/20260503_seed_chart_of_accounts.php new file mode 100644 index 0000000..f74fccd --- /dev/null +++ b/db/migrations/20260503_seed_chart_of_accounts.php @@ -0,0 +1,9 @@ +prepare( + "SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? LIMIT 1" + ); + $stmt->execute([$tableName]); + $cache[$normalized] = (bool)$stmt->fetchColumn(); + } catch (Throwable $e) { + error_log('Accounting table check failed: ' . $e->getMessage()); + $cache[$normalized] = false; + } + + return $cache[$normalized]; +} + +function getDefaultAccountingAccounts(): array { + return [ + ['code' => '1000', 'name_en' => 'Assets', 'name_ar' => 'الأصول', 'type' => 'asset', 'parent_code' => null], + ['code' => '1100', 'name_en' => 'Cash on Hand', 'name_ar' => 'النقدية بالصندوق', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1110', 'name_en' => 'Petty Cash', 'name_ar' => 'العهدة النقدية', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1200', 'name_en' => 'Bank Account', 'name_ar' => 'حساب البنك', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1300', 'name_en' => 'Accounts Receivable', 'name_ar' => 'ذمم العملاء', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1400', 'name_en' => 'Inventory', 'name_ar' => 'المخزون', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1500', 'name_en' => 'VAT Input', 'name_ar' => 'ضريبة المدخلات', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1600', 'name_en' => 'Fixed Assets', 'name_ar' => 'الأصول الثابتة', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '1700', 'name_en' => 'Prepaid Expenses', 'name_ar' => 'المصروفات المقدمة', 'type' => 'asset', 'parent_code' => '1000'], + ['code' => '2000', 'name_en' => 'Liabilities', 'name_ar' => 'الالتزامات', 'type' => 'liability', 'parent_code' => null], + ['code' => '2100', 'name_en' => 'Accounts Payable', 'name_ar' => 'ذمم الموردين', 'type' => 'liability', 'parent_code' => '2000'], + ['code' => '2200', 'name_en' => 'Payroll Liabilities', 'name_ar' => 'التزامات الرواتب', 'type' => 'liability', 'parent_code' => '2000'], + ['code' => '2300', 'name_en' => 'VAT Payable', 'name_ar' => 'ضريبة المخرجات', 'type' => 'liability', 'parent_code' => '2000'], + ['code' => '2400', 'name_en' => 'Accrued Expenses', 'name_ar' => 'مصروفات مستحقة', 'type' => 'liability', 'parent_code' => '2000'], + ['code' => '3000', 'name_en' => 'Equity', 'name_ar' => 'حقوق الملكية', 'type' => 'equity', 'parent_code' => null], + ['code' => '3100', 'name_en' => 'Owner Capital', 'name_ar' => 'رأس المال', 'type' => 'equity', 'parent_code' => '3000'], + ['code' => '3200', 'name_en' => 'Retained Earnings', 'name_ar' => 'الأرباح المحتجزة', 'type' => 'equity', 'parent_code' => '3000'], + ['code' => '4000', 'name_en' => 'Revenue', 'name_ar' => 'الإيرادات', 'type' => 'revenue', 'parent_code' => null], + ['code' => '4100', 'name_en' => 'Sales Revenue', 'name_ar' => 'إيرادات المبيعات', 'type' => 'revenue', 'parent_code' => '4000'], + ['code' => '4200', 'name_en' => 'Service Revenue', 'name_ar' => 'إيرادات الخدمات', 'type' => 'revenue', 'parent_code' => '4000'], + ['code' => '5000', 'name_en' => 'Expenses', 'name_ar' => 'المصروفات', 'type' => 'expense', 'parent_code' => null], + ['code' => '5100', 'name_en' => 'Cost of Goods Sold', 'name_ar' => 'تكلفة البضاعة المباعة', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5200', 'name_en' => 'Operating Expenses', 'name_ar' => 'المصروفات التشغيلية', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5300', 'name_en' => 'Payroll Expenses', 'name_ar' => 'مصروفات الرواتب', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5400', 'name_en' => 'Rent Expense', 'name_ar' => 'مصروف الإيجار', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5500', 'name_en' => 'Utilities Expense', 'name_ar' => 'مصروف المرافق', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5600', 'name_en' => 'Marketing Expense', 'name_ar' => 'مصروف التسويق', 'type' => 'expense', 'parent_code' => '5000'], + ['code' => '5700', 'name_en' => 'Transport Expense', 'name_ar' => 'مصروف النقل', 'type' => 'expense', 'parent_code' => '5000'], + ]; +} + +function seedDefaultAccountingAccounts(): int { + if (!accountingTableExists('acc_accounts')) { + return 0; + } + + $db = db(); + $accounts = getDefaultAccountingAccounts(); + $inserted = 0; + $startedTransaction = false; + + try { + if (!$db->inTransaction()) { + $db->beginTransaction(); + $startedTransaction = true; + } + + $selectId = $db->prepare("SELECT id FROM acc_accounts WHERE code = ? LIMIT 1"); + $insert = $db->prepare( + "INSERT INTO acc_accounts (code, name_en, name_ar, type, parent_id) VALUES (?, ?, ?, ?, NULL)" + ); + $updateParent = $db->prepare("UPDATE acc_accounts SET parent_id = ? WHERE code = ?"); + + foreach ($accounts as $account) { + $selectId->execute([$account['code']]); + if (!$selectId->fetchColumn()) { + $insert->execute([ + $account['code'], + $account['name_en'], + $account['name_ar'], + $account['type'], + ]); + $inserted++; + } + } + + foreach ($accounts as $account) { + if (empty($account['parent_code'])) { + continue; + } + + $selectId->execute([$account['parent_code']]); + $parentId = $selectId->fetchColumn(); + if ($parentId) { + $updateParent->execute([(int)$parentId, $account['code']]); + } + } + + if ($startedTransaction) { + $db->commit(); + } + } catch (Throwable $e) { + if ($startedTransaction && $db->inTransaction()) { + $db->rollBack(); + } + error_log('Accounting seed failed: ' . $e->getMessage()); + return 0; + } + + return $inserted; +} + +function createAccountingAccount(string $code, string $nameEn, string $nameAr, string $type, ?int $parentId = null): array { + if (!accountingTableExists('acc_accounts')) { + return ['success' => false, 'error' => 'Accounting tables are not ready yet.']; + } + + $code = strtoupper(trim($code)); + $nameEn = trim($nameEn); + $nameAr = trim($nameAr); + $allowedTypes = ['asset', 'liability', 'equity', 'revenue', 'expense']; + + if ($code === '' || !preg_match('/^[A-Z0-9._-]{1,20}$/', $code)) { + return ['success' => false, 'error' => 'Please enter a valid unique account code.']; + } + if ($nameEn === '' || $nameAr === '') { + return ['success' => false, 'error' => 'English and Arabic account names are required.']; + } + if (!in_array($type, $allowedTypes, true)) { + return ['success' => false, 'error' => 'Invalid account type selected.']; + } + + $db = db(); + + try { + if ($parentId !== null) { + $stmtParent = $db->prepare("SELECT id, type FROM acc_accounts WHERE id = ? LIMIT 1"); + $stmtParent->execute([$parentId]); + $parent = $stmtParent->fetch(PDO::FETCH_ASSOC); + + if (!$parent) { + return ['success' => false, 'error' => 'Selected parent account was not found.']; + } + if (($parent['type'] ?? '') !== $type) { + return ['success' => false, 'error' => 'Parent and child accounts must use the same type.']; + } + } + + $stmtExists = $db->prepare("SELECT id FROM acc_accounts WHERE code = ? LIMIT 1"); + $stmtExists->execute([$code]); + if ($stmtExists->fetchColumn()) { + return ['success' => false, 'error' => 'That account code already exists.']; + } + + $stmt = $db->prepare( + "INSERT INTO acc_accounts (code, name_en, name_ar, type, parent_id) VALUES (?, ?, ?, ?, ?)" + ); + $stmt->execute([$code, $nameEn, $nameAr, $type, $parentId ?: null]); + + return ['success' => true, 'id' => (int)$db->lastInsertId()]; + } catch (Throwable $e) { + error_log('Accounting account create failed: ' . $e->getMessage()); + return ['success' => false, 'error' => 'Unable to save the account right now.']; + } +} + function createJournalEntry($date, $description, $reference, $source_type, $source_id, $items) { $db = db(); try { diff --git a/index.php b/index.php index a6e3cf7..02a208c 100644 --- a/index.php +++ b/index.php @@ -5154,9 +5154,12 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System';
= nl2br(htmlspecialchars($data['settings']['company_address'] ?? '')) ?>
Use this page to seed the main parent accounts, then add custom child accounts under the right parent.
+| Code | Name | Type | +Level | Parent | Balance | ||||
|---|---|---|---|---|---|---|---|---|---|
| = $acc['code'] ?> | += htmlspecialchars($acc['code']) ?> |
= htmlspecialchars($acc['name_en']) ?> = htmlspecialchars($acc['name_ar']) ?> |
- = $acc['type'] ?> | -= htmlspecialchars($acc['parent_name'] ?? '---') ?> | -= number_format(getAccountBalance($acc['code']), 3) ?> | += htmlspecialchars($acc['type']) ?> | ++ + Main + + Child + + | += htmlspecialchars($acc['parent_name'] ?? '— Main Account —') ?> | += number_format((float)getAccountBalance($acc['code']), 3) ?> |