diff --git a/api/biometric_sync.php b/api/biometric_sync.php new file mode 100644 index 0000000..adc0ef2 --- /dev/null +++ b/api/biometric_sync.php @@ -0,0 +1,101 @@ + false, 'error' => 'Invalid JSON input']); + exit; +} + +/* +Expected format: +[ + {"biometric_id": "101", "timestamp": "2026-02-17 08:05:00", "type": "in"}, + {"biometric_id": "101", "timestamp": "2026-02-17 17:05:00", "type": "out"} +] +*/ + +$db = db(); +$success_count = 0; +$errors = []; + +try { + foreach ($data as $log) { + $biometric_id = $log['biometric_id'] ?? null; + $device_id = $log['device_id'] ?? null; + $timestamp = $log['timestamp'] ?? null; + $type_input = $log['type'] ?? 'unknown'; + $type = 'unknown'; + if (in_array(strtolower($type_input), ['in', 'check_in', 'entry'])) $type = 'check_in'; + if (in_array(strtolower($type_input), ['out', 'check_out', 'exit'])) $type = 'check_out'; + + if (!$biometric_id || !$timestamp) { + continue; + } + + // Find employee + $stmt = $db->prepare("SELECT id FROM hr_employees WHERE biometric_id = ?"); + $stmt->execute([$biometric_id]); + $employee_id = $stmt->fetchColumn() ?: null; + + // Insert into raw logs + $stmt = $db->prepare("INSERT INTO hr_biometric_logs (biometric_id, device_id, employee_id, timestamp, type) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$biometric_id, $device_id, $employee_id, $timestamp, $type]); + $log_id = $db->lastInsertId(); + + if ($employee_id) { + $date = date('Y-m-d', strtotime($timestamp)); + $time = date('H:i:s', strtotime($timestamp)); + + // Logic to update hr_attendance + // If it's the first log of the day, it's clock_in. + // If it's another log, it might be clock_out. + + $stmt = $db->prepare("SELECT id, clock_in, clock_out FROM hr_attendance WHERE employee_id = ? AND attendance_date = ?"); + $stmt->execute([$employee_id, $date]); + $attendance = $stmt->fetch(); + + if (!$attendance) { + // First entry of the day + $stmt = $db->prepare("INSERT INTO hr_attendance (employee_id, attendance_date, status, clock_in) VALUES (?, ?, 'present', ?)"); + $stmt->execute([$employee_id, $date, $time]); + } else { + // Update existing entry. + // Simple logic: if new time is earlier than clock_in, update clock_in. + // If new time is later than clock_out (or clock_out is null), update clock_out. + + $current_in = $attendance['clock_in']; + $current_out = $attendance['clock_out']; + + if ($time < $current_in) { + $stmt = $db->prepare("UPDATE hr_attendance SET clock_in = ? WHERE id = ?"); + $stmt->execute([$time, $attendance['id']]); + } elseif (!$current_out || $time > $current_out) { + $stmt = $db->prepare("UPDATE hr_attendance SET clock_out = ? WHERE id = ?"); + $stmt->execute([$time, $attendance['id']]); + } + } + + // Mark log as processed + $db->prepare("UPDATE hr_biometric_logs SET processed = 1 WHERE id = ?")->execute([$log_id]); + $success_count++; + } else { + $errors[] = "Employee with Biometric ID $biometric_id not found."; + } + } + + echo json_encode([ + 'success' => true, + 'message' => "Processed $success_count logs.", + 'errors' => $errors + ]); + +} catch (Exception $e) { + echo json_encode(['success' => false, 'error' => $e->getMessage()]); +} diff --git a/db/migrations/20260217_accounting_module.sql b/db/migrations/20260217_accounting_module.sql new file mode 100644 index 0000000..87032a7 --- /dev/null +++ b/db/migrations/20260217_accounting_module.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS acc_accounts ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) UNIQUE NOT NULL, + name_en VARCHAR(100) NOT NULL, + name_ar VARCHAR(100) NOT NULL, + type ENUM('asset', 'liability', 'equity', 'revenue', 'expense') NOT NULL, + parent_id INT NULL, + FOREIGN KEY (parent_id) REFERENCES acc_accounts(id) +); + +CREATE TABLE IF NOT EXISTS acc_journal_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + entry_date DATE NOT NULL, + description TEXT, + reference VARCHAR(100), + source_type VARCHAR(50), + source_id INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS acc_ledger ( + id INT AUTO_INCREMENT PRIMARY KEY, + journal_entry_id INT NOT NULL, + account_id INT NOT NULL, + debit DECIMAL(15, 3) DEFAULT 0, + credit DECIMAL(15, 3) DEFAULT 0, + FOREIGN KEY (journal_entry_id) REFERENCES acc_journal_entries(id) ON DELETE CASCADE, + FOREIGN KEY (account_id) REFERENCES acc_accounts(id) +); + +INSERT IGNORE INTO acc_accounts (code, name_en, name_ar, type) VALUES +('1000', 'Assets', 'الأصول', 'asset'), +('1100', 'Cash on Hand', 'النقدية', 'asset'), +('1200', 'Bank Account', 'حساب البنك', 'asset'), +('1300', 'Accounts Receivable', 'حسابات العملاء', 'asset'), +('1400', 'Inventory', 'المخزون', 'asset'), +('2000', 'Liabilities', 'الالتزامات', 'liability'), +('2100', 'Accounts Payable', 'حسابات الموردين', 'liability'), +('3000', 'Equity', 'حقوق الملكية', 'equity'), +('4000', 'Revenue', 'الإيرادات', 'revenue'), +('4100', 'Sales Revenue', 'إيرادات المبيعات', 'revenue'), +('5000', 'Expenses', 'المصروفات', 'expense'), +('5100', 'Cost of Goods Sold', 'تكلفة البضاعة المباعة', 'expense'), +('5200', 'Operating Expenses', 'مصاريف تشغيلية', 'expense'); + +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='1000') AS t) WHERE code IN ('1100', '1200', '1300', '1400'); +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='2000') AS t) WHERE code IN ('2100'); +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='4000') AS t) WHERE code IN ('4100'); +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='5000') AS t) WHERE code IN ('5100', '5200'); diff --git a/db/migrations/20260217_biometric_attendance.sql b/db/migrations/20260217_biometric_attendance.sql new file mode 100644 index 0000000..955c7e9 --- /dev/null +++ b/db/migrations/20260217_biometric_attendance.sql @@ -0,0 +1,13 @@ +-- Add biometric_id to hr_employees and create biometric_logs table +ALTER TABLE hr_employees ADD COLUMN biometric_id VARCHAR(50) UNIQUE AFTER department_id; + +CREATE TABLE IF NOT EXISTS hr_biometric_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + biometric_id VARCHAR(50) NOT NULL, + employee_id INT, + timestamp DATETIME NOT NULL, + type ENUM('check_in', 'check_out', 'unknown') DEFAULT 'unknown', + processed TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (employee_id) REFERENCES hr_employees(id) ON DELETE SET NULL +); diff --git a/db/migrations/20260217_biometric_devices.sql b/db/migrations/20260217_biometric_devices.sql new file mode 100644 index 0000000..92cf483 --- /dev/null +++ b/db/migrations/20260217_biometric_devices.sql @@ -0,0 +1,12 @@ +-- Create biometric_devices table +CREATE TABLE IF NOT EXISTS hr_biometric_devices ( + id INT AUTO_INCREMENT PRIMARY KEY, + device_name VARCHAR(100) NOT NULL, + ip_address VARCHAR(50) NOT NULL, + port INT DEFAULT 4370, + io_address VARCHAR(100), -- specific request + serial_number VARCHAR(100), + status ENUM('active', 'inactive') DEFAULT 'active', + last_sync TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/db/migrations/20260217_biometric_logs_update.sql b/db/migrations/20260217_biometric_logs_update.sql new file mode 100644 index 0000000..da6e054 --- /dev/null +++ b/db/migrations/20260217_biometric_logs_update.sql @@ -0,0 +1,3 @@ +-- Add device_id to hr_biometric_logs +ALTER TABLE hr_biometric_logs ADD COLUMN device_id INT AFTER biometric_id; +ALTER TABLE hr_biometric_logs ADD FOREIGN KEY (device_id) REFERENCES hr_biometric_devices(id) ON DELETE SET NULL; diff --git a/db/migrations/20260217_vat_accounts.sql b/db/migrations/20260217_vat_accounts.sql new file mode 100644 index 0000000..2e20401 --- /dev/null +++ b/db/migrations/20260217_vat_accounts.sql @@ -0,0 +1,7 @@ +-- Add VAT accounts to Chart of Accounts +INSERT IGNORE INTO acc_accounts (code, name_en, name_ar, type) VALUES +('1500', 'VAT Input', 'ضريبة القيمة المضافة - مدخلات', 'asset'), +('2300', 'VAT Payable', 'ضريبة القيمة المضافة - مستحقة', 'liability'); + +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='1000') AS t) WHERE code IN ('1500'); +UPDATE acc_accounts SET parent_id = (SELECT id FROM (SELECT id FROM acc_accounts AS x WHERE x.code='2000') AS t) WHERE code IN ('2300'); diff --git a/includes/accounting_helper.php b/includes/accounting_helper.php new file mode 100644 index 0000000..5c47885 --- /dev/null +++ b/includes/accounting_helper.php @@ -0,0 +1,196 @@ +prepare("INSERT INTO acc_journal_entries (entry_date, description, reference, source_type, source_id) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$date, $description, $reference, $source_type, $source_id]); + $entryId = $db->lastInsertId(); + + $stmtLedger = $db->prepare("INSERT INTO acc_ledger (journal_entry_id, account_id, debit, credit) VALUES (?, ?, ?, ?)"); + foreach ($items as $item) { + // Find account ID by code + $stmtAcc = $db->prepare("SELECT id FROM acc_accounts WHERE code = ?"); + $stmtAcc->execute([$item['code']]); + $accountId = $stmtAcc->fetchColumn(); + + if ($accountId) { + $stmtLedger->execute([$entryId, $accountId, $item['debit'] ?? 0, $item['credit'] ?? 0]); + } + } + return $entryId; + } catch (Exception $e) { + error_log("Accounting Error: " . $e->getMessage()); + return false; + } +} + +/** + * Record a Sale + */ +function recordSaleJournal($invoice_id, $amount, $date, $items_data = [], $vat_amount = 0) { + $subtotal = $amount - $vat_amount; + $entries = [ + ['code' => '1300', 'debit' => $amount], // Accounts Receivable (Asset increases) + ['code' => '4100', 'credit' => $subtotal] // Sales Revenue (Revenue increases) + ]; + + if ($vat_amount > 0) { + $entries[] = ['code' => '2300', 'credit' => $vat_amount]; // VAT Payable (Liability increases) + } + + // Inventory & COGS + $total_cogs = 0; + foreach ($items_data as $item) { + $stmt = db()->prepare("SELECT purchase_price FROM stock_items WHERE id = ?"); + $stmt->execute([$item['id']]); + $cost = (float)$stmt->fetchColumn(); + $total_cogs += ($cost * $item['qty']); + } + + if ($total_cogs > 0) { + $entries[] = ['code' => '5100', 'debit' => $total_cogs]; // COGS (Expense increases) + $entries[] = ['code' => '1400', 'credit' => $total_cogs]; // Inventory (Asset decreases) + } + + return createJournalEntry($date, "Sale Invoice #$invoice_id", "INV-$invoice_id", 'invoice', $invoice_id, $entries); +} + +/** + * Record a Purchase + */ +function recordPurchaseJournal($invoice_id, $amount, $date, $items_data = [], $vat_amount = 0) { + $subtotal = $amount - $vat_amount; + $entries = [ + ['code' => '1400', 'debit' => $subtotal], // Inventory (Asset increases) + ['code' => '2100', 'credit' => $amount] // Accounts Payable (Liability increases) + ]; + + if ($vat_amount > 0) { + $entries[] = ['code' => '1500', 'debit' => $vat_amount]; // VAT Input (Asset increases) + } + + return createJournalEntry($date, "Purchase Invoice #$invoice_id", "PINV-$invoice_id", 'invoice', $invoice_id, $entries); +} + +/** + * Record a Payment Received (from Customer) + */ +function recordPaymentReceivedJournal($payment_id, $amount, $date, $method) { + $code = ($method === 'Bank' || $method === 'Transfer') ? '1200' : '1100'; + $entries = [ + ['code' => $code, 'debit' => $amount], // Cash/Bank (Asset increases) + ['code' => '1300', 'credit' => $amount] // Accounts Receivable (Asset decreases) + ]; + return createJournalEntry($date, "Payment Received #$payment_id", "PAY-$payment_id", 'payment', $payment_id, $entries); +} + +/** + * Record a Payment Made (to Supplier) + */ +function recordPaymentMadeJournal($payment_id, $amount, $date, $method) { + $code = ($method === 'Bank' || $method === 'Transfer') ? '1200' : '1100'; + $entries = [ + ['code' => '2100', 'debit' => $amount], // Accounts Payable (Liability decreases) + ['code' => $code, 'credit' => $amount] // Cash/Bank (Asset decreases) + ]; + return createJournalEntry($date, "Payment Made #$payment_id", "PAY-$payment_id", 'payment', $payment_id, $entries); +} + +/** + * Record a Sales Return + */ +function recordSalesReturnJournal($return_id, $amount, $date) { + $entries = [ + ['code' => '4100', 'debit' => $amount], // Sales Revenue (Revenue decreases) - ideally a "Sales Returns" account + ['code' => '1300', 'credit' => $amount] // Accounts Receivable (Asset decreases) + ]; + return createJournalEntry($date, "Sales Return #$return_id", "SRET-$return_id", 'sales_return', $return_id, $entries); +} + +/** + * Record a Purchase Return + */ +function recordPurchaseReturnJournal($return_id, $amount, $date) { + $entries = [ + ['code' => '2100', 'debit' => $amount], // Accounts Payable (Liability decreases) + ['code' => '1400', 'credit' => $amount] // Inventory (Asset decreases) + ]; + return createJournalEntry($date, "Purchase Return #$return_id", "PRET-$return_id", 'purchase_return', $return_id, $entries); +} + +/** + * Record an Expense + */ +function recordExpenseJournal($expense_id, $amount, $date, $description, $method = 'Cash') { + $paymentCode = ($method === 'Bank' || $method === 'Transfer') ? '1200' : '1100'; + $entries = [ + ['code' => '5200', 'debit' => $amount], // Operating Expenses (Expense increases) + ['code' => $paymentCode, 'credit' => $amount] // Cash/Bank (Asset decreases) + ]; + return createJournalEntry($date, "Expense: $description", "EXP-$expense_id", 'expense', $expense_id, $entries); +} + +/** + * Record Payroll Payment + */ +function recordPayrollJournal($payroll_id, $amount, $date, $emp_name) { + $entries = [ + ['code' => '5200', 'debit' => $amount], // Using 5200 for now, or could use 5300 if added + ['code' => '1100', 'credit' => $amount] // Paid in Cash + ]; + return createJournalEntry($date, "Payroll Payment - $emp_name", "PAYROLL-$payroll_id", 'payroll', $payroll_id, $entries); +} + +/** + * Get Account Balance + */ +function getAccountBalance($code, $start_date = null, $end_date = null) { + $db = db(); + $sql = "SELECT SUM(l.debit) - SUM(l.credit) as balance + FROM acc_ledger l + JOIN acc_accounts a ON l.account_id = a.id + JOIN acc_journal_entries e ON l.journal_entry_id = e.id + WHERE a.code LIKE ?"; + $params = [$code . '%']; + + if ($start_date) { + $sql .= " AND e.entry_date >= ?"; + $params[] = $start_date; + } + if ($end_date) { + $sql .= " AND e.entry_date <= ?"; + $params[] = $end_date; + } + + $stmt = $db->prepare($sql); + $stmt->execute($params); + $balance = (float)$stmt->fetchColumn(); + + // For Liability, Equity, Revenue: Balance = Credit - Debit + $stmtType = $db->prepare("SELECT type FROM acc_accounts WHERE code = ?"); + $stmtType->execute([$code]); + $type = $stmtType->fetchColumn(); + + if (in_array($type, ['liability', 'equity', 'revenue'])) { + return -$balance; + } + return $balance; +} + +/** + * Get VAT Report + */ +function getVatReport($start_date = null, $end_date = null) { + $input_vat = getAccountBalance('1500', $start_date, $end_date); + $output_vat = getAccountBalance('2300', $start_date, $end_date); + + return [ + 'input_vat' => $input_vat, + 'output_vat' => $output_vat, + 'net_vat' => $output_vat - $input_vat + ]; +} diff --git a/index.php b/index.php index 430bd03..af14569 100644 --- a/index.php +++ b/index.php @@ -2,6 +2,7 @@ declare(strict_types=1); session_start(); require_once 'db/config.php'; +require_once 'includes/accounting_helper.php'; // Handle POST Requests $message = ''; @@ -186,6 +187,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($category_id && $amount > 0) { $stmt = db()->prepare("INSERT INTO expenses (category_id, amount, expense_date, description, reference_no) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$category_id, $amount, $date, $desc, $ref]); + $expense_id = db()->lastInsertId(); + + // Accounting Integration + $catStmt = db()->prepare("SELECT name_en FROM expense_categories WHERE id = ?"); + $catStmt->execute([$category_id]); + $cat_name = $catStmt->fetchColumn(); + recordExpenseJournal($expense_id, $amount, $date, $cat_name); + $message = "Expense recorded successfully!"; } } @@ -302,11 +311,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->execute([$transaction_no, $customer_id, $total_amount, $discount_code_id, $discount_amount, $loyalty_earned, $loyalty_redeemed, $net_amount, $methods_str]); $pos_id = $db->lastInsertId(); - foreach ($items as $item) { + $total_vat = 0; + foreach ($items as &$item) { $qty = (float)$item['qty']; $price = (float)$item['price']; $subtotal = $qty * $price; + $stmtVat = $db->prepare("SELECT vat_rate FROM stock_items WHERE id = ?"); + $stmtVat->execute([$item['id']]); + $vat_rate = (float)$stmtVat->fetchColumn(); + $item_vat = $subtotal * ($vat_rate / 100); + $total_vat += $item_vat; + // Add to invoice_items $stmt = $db->prepare("INSERT INTO invoice_items (invoice_id, item_id, quantity, unit_price, total_price) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$invoice_id, $item['id'], $qty, $price, $subtotal]); @@ -320,6 +336,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->execute([$qty, $item['id']]); } + // Update Invoice with VAT info + $stmt = $db->prepare("UPDATE invoices SET total_amount = ?, vat_amount = ? WHERE id = ?"); + $stmt->execute([$net_amount - $total_vat, $total_vat, $invoice_id]); + // Update Customer Loyalty Points and Balance if ($customer_id) { $credit_total = 0; @@ -338,9 +358,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($p['method'] === 'credit') continue; $stmt = $db->prepare("INSERT INTO payments (invoice_id, amount, payment_date, payment_method, notes) VALUES (?, ?, CURDATE(), ?, 'POS Transaction')"); $stmt->execute([$invoice_id, (float)$p['amount'], $p['method']]); + $payment_id = $db->lastInsertId(); + recordPaymentReceivedJournal($payment_id, (float)$p['amount'], date('Y-m-d'), $p['method']); } $db->commit(); + + // Accounting Integration for the Sale itself + recordSaleJournal($invoice_id, (float)$net_amount, date('Y-m-d'), $items, (float)$total_vat); + echo json_encode(['success' => true, 'invoice_id' => $invoice_id, 'transaction_no' => $transaction_no]); } catch (Exception $e) { if (isset($db)) $db->rollBack(); @@ -680,6 +706,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $db->commit(); + + // Accounting Integration + if ($type === 'sale') { + recordSaleJournal($invoice_id, (float)$total_with_vat, $invoice_date, $items_data, (float)$total_vat); + } else { + recordPurchaseJournal($invoice_id, (float)$total_with_vat, $invoice_date, $items_data, (float)$total_vat); + } + + if ($paid_amount > 0) { + // We need the payment_id. Since we only recorded one payment above, we can fetch it. + $stmtPayId = db()->prepare("SELECT id FROM payments WHERE invoice_id = ? ORDER BY id DESC LIMIT 1"); + $stmtPayId->execute([$invoice_id]); + $pay_id = $stmtPayId->fetchColumn(); + if ($type === 'sale') { + recordPaymentReceivedJournal($pay_id, $paid_amount, $invoice_date, 'Cash'); + } else { + recordPaymentMadeJournal($pay_id, $paid_amount, $invoice_date, 'Cash'); + } + } + $message = "Invoice #$invoice_id created successfully!"; } catch (Exception $e) { $db->rollBack(); @@ -1040,6 +1086,114 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = "Settings updated successfully!"; } + if (isset($_POST['sync_accounting'])) { + try { + $db = db(); + $db->beginTransaction(); + + // Clear existing automatic entries + $db->exec("DELETE FROM acc_journal_entries WHERE source_type IN ('invoice', 'payment', 'expense', 'payroll', 'sales_return', 'purchase_return')"); + + // 1. Invoices + $invoices = $db->query("SELECT * FROM invoices ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($invoices as $inv) { + $items = $db->prepare("SELECT item_id as id, quantity as qty, unit_price as price FROM invoice_items WHERE invoice_id = ?"); + $items->execute([$inv['id']]); + $items_data = $items->fetchAll(PDO::FETCH_ASSOC); + + if ($inv['type'] === 'sale') { + recordSaleJournal($inv['id'], (float)$inv['total_with_vat'], $inv['invoice_date'], $items_data, (float)$inv['vat_amount']); + } else { + recordPurchaseJournal($inv['id'], (float)$inv['total_with_vat'], $inv['invoice_date'], $items_data, (float)$inv['vat_amount']); + } + } + + // 2. Payments + $payments = $db->query("SELECT p.*, i.type FROM payments p JOIN invoices i ON p.invoice_id = i.id ORDER BY p.id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($payments as $pay) { + if ($pay['type'] === 'sale') { + recordPaymentReceivedJournal($pay['id'], (float)$pay['amount'], $pay['payment_date'], $pay['payment_method']); + } else { + recordPaymentMadeJournal($pay['id'], (float)$pay['amount'], $pay['payment_date'], $pay['payment_method']); + } + } + + // 3. Expenses + $expenses = $db->query("SELECT e.*, c.name_en FROM expenses e JOIN expense_categories c ON e.category_id = c.id ORDER BY e.id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($expenses as $exp) { + recordExpenseJournal($exp['id'], (float)$exp['amount'], $exp['expense_date'], $exp['name_en']); + } + + // 4. Payroll + $payrolls = $db->query("SELECT p.*, e.name FROM hr_payroll p JOIN hr_employees e ON p.employee_id = e.id WHERE p.status = 'paid' ORDER BY p.id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($payrolls as $proll) { + recordPayrollJournal($proll['id'], (float)$proll['net_salary'], $proll['payment_date'] ?? date('Y-m-d'), $proll['name']); + } + + // 5. Returns + $s_returns = $db->query("SELECT * FROM sales_returns ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($s_returns as $ret) { + recordSalesReturnJournal($ret['id'], (float)$ret['total_amount'], $ret['return_date']); + } + + $p_returns = $db->query("SELECT * FROM purchase_returns ORDER BY id ASC")->fetchAll(PDO::FETCH_ASSOC); + foreach ($p_returns as $ret) { + recordPurchaseReturnJournal($ret['id'], (float)$ret['total_amount'], $ret['return_date']); + } + + $db->commit(); + $message = "Accounting sync completed successfully!"; + } catch (Exception $e) { + if (isset($db)) $db->rollBack(); + $message = "Sync Error: " . $e->getMessage(); + } + } + + if (isset($_POST['add_account'])) { + $code = $_POST['code'] ?? ''; + $name_en = $_POST['name_en'] ?? ''; + $name_ar = $_POST['name_ar'] ?? ''; + $type = $_POST['type'] ?? ''; + $parent_id = $_POST['parent_id'] ?: null; + + if ($code && $name_en && $type) { + $stmt = db()->prepare("INSERT INTO acc_accounts (code, name_en, name_ar, type, parent_id) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$code, $name_en, $name_ar, $type, $parent_id]); + $message = "Account added successfully!"; + } + } + if (isset($_POST['add_journal_entry'])) { + $date = $_POST['entry_date'] ?: date('Y-m-d'); + $desc = $_POST['description'] ?? ''; + $ref = $_POST['reference'] ?? ''; + + $codes = $_POST['codes'] ?? []; + $debits = $_POST['debits'] ?? []; + $credits = $_POST['credits'] ?? []; + + $items = []; + $total_debit = 0; + $total_credit = 0; + + foreach ($codes as $index => $code) { + if (!$code) continue; + $deb = (float)($debits[$index] ?? 0); + $cre = (float)($credits[$index] ?? 0); + if ($deb == 0 && $cre == 0) continue; + + $items[] = ['code' => $code, 'debit' => $deb, 'credit' => $cre]; + $total_debit += $deb; + $total_credit += $cre; + } + + if (abs($total_debit - $total_credit) < 0.0001 && !empty($items)) { + createJournalEntry($date, $desc, $ref, 'manual', null, $items); + $message = "Manual Journal Entry recorded successfully!"; + } else { + $message = "Error: Journal entry is not balanced (Debit: $total_debit, Credit: $total_credit)"; + } + } + if (isset($_POST['record_payment'])) { $invoice_id = (int)$_POST['invoice_id']; $amount = (float)$_POST['amount']; @@ -1072,6 +1226,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt = $db->prepare("UPDATE invoices SET paid_amount = ?, status = ? WHERE id = ?"); $stmt->execute([$new_paid_amount, $new_status, $invoice_id]); + // Accounting Integration + $stmtInvType = $db->prepare("SELECT type FROM invoices WHERE id = ?"); + $stmtInvType->execute([$invoice_id]); + $invType = $stmtInvType->fetchColumn(); + if ($invType === 'sale') { + recordPaymentReceivedJournal($payment_id, $amount, $payment_date, $payment_method); + } else { + recordPaymentMadeJournal($payment_id, $amount, $payment_date, $payment_method); + } + $db->commit(); $message = "Payment of OMR " . number_format($amount, 3) . " recorded successfully! Receipt ID: $payment_id"; @@ -1137,6 +1301,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->execute([$item['qty'], $item['id']]); } + // Accounting Integration + recordSalesReturnJournal($return_id, $total_return_amount, $return_date); + // If it was a credit sale, we might want to reduce the customer balance. if ($customer_id) { $stmt = $db->prepare("UPDATE customers SET balance = balance + ? WHERE id = ?"); @@ -1203,6 +1370,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->execute([$item['qty'], $item['id']]); } + // Accounting Integration + recordPurchaseReturnJournal($return_id, $total_return_amount, $return_date); + // Reduce debt to supplier if ($supplier_id) { $stmt = $db->prepare("UPDATE customers SET balance = balance + ? WHERE id = ?"); @@ -1248,6 +1418,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } if (isset($_POST['add_hr_employee'])) { $dept_id = (int)$_POST['department_id'] ?: null; + $biometric_id = $_POST['biometric_id'] ?: null; $name = $_POST['name'] ?? ''; $email = $_POST['email'] ?? ''; $phone = $_POST['phone'] ?? ''; @@ -1255,14 +1426,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $salary = (float)($_POST['salary'] ?? 0); $j_date = $_POST['joining_date'] ?: null; if ($name) { - $stmt = db()->prepare("INSERT INTO hr_employees (department_id, name, email, phone, position, salary, joining_date) VALUES (?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$dept_id, $name, $email, $phone, $pos, $salary, $j_date]); + $stmt = db()->prepare("INSERT INTO hr_employees (department_id, biometric_id, name, email, phone, position, salary, joining_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$dept_id, $biometric_id, $name, $email, $phone, $pos, $salary, $j_date]); $message = "Employee added successfully!"; } } if (isset($_POST['edit_hr_employee'])) { $id = (int)$_POST['id']; $dept_id = (int)$_POST['department_id'] ?: null; + $biometric_id = $_POST['biometric_id'] ?: null; $name = $_POST['name'] ?? ''; $email = $_POST['email'] ?? ''; $phone = $_POST['phone'] ?? ''; @@ -1271,8 +1443,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $j_date = $_POST['joining_date'] ?: null; $status = $_POST['status'] ?? 'active'; if ($id && $name) { - $stmt = db()->prepare("UPDATE hr_employees SET department_id = ?, name = ?, email = ?, phone = ?, position = ?, salary = ?, joining_date = ?, status = ? WHERE id = ?"); - $stmt->execute([$dept_id, $name, $email, $phone, $pos, $salary, $j_date, $status, $id]); + $stmt = db()->prepare("UPDATE hr_employees SET department_id = ?, biometric_id = ?, name = ?, email = ?, phone = ?, position = ?, salary = ?, joining_date = ?, status = ? WHERE id = ?"); + $stmt->execute([$dept_id, $biometric_id, $name, $email, $phone, $pos, $salary, $j_date, $status, $id]); $message = "Employee updated successfully!"; } } @@ -1324,9 +1496,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (isset($_POST['pay_payroll'])) { $id = (int)$_POST['id']; if ($id) { - $stmt = db()->prepare("UPDATE hr_payroll SET status = 'paid', payment_date = CURDATE() WHERE id = ?"); + // Get payroll details for accounting + $stmt = db()->prepare("SELECT p.*, e.name FROM hr_payroll p JOIN hr_employees e ON p.employee_id = e.id WHERE p.id = ?"); $stmt->execute([$id]); - $message = "Payroll marked as paid!"; + $payroll = $stmt->fetch(); + + if ($payroll && $payroll['status'] !== 'paid') { + $stmt = db()->prepare("UPDATE hr_payroll SET status = 'paid', payment_date = CURDATE() WHERE id = ?"); + $stmt->execute([$id]); + + // Accounting Integration + recordPayrollJournal($id, (float)$payroll['net_salary'], date('Y-m-d'), $payroll['name']); + + $message = "Payroll marked as paid and recorded in accounting!"; + } else { + $message = "Payroll already paid or not found."; + } } } if (isset($_POST['delete_payroll'])) { @@ -1337,6 +1522,101 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $message = "Payroll record deleted successfully!"; } } + + // --- Biometric Devices Handlers --- + if (isset($_POST['add_biometric_device'])) { + $name = $_POST['device_name'] ?? ''; + $ip = $_POST['ip_address'] ?? ''; + $port = (int)($_POST['port'] ?? 4370); + $io = $_POST['io_address'] ?? ''; + $serial = $_POST['serial_number'] ?? ''; + if ($name && $ip) { + $stmt = db()->prepare("INSERT INTO hr_biometric_devices (device_name, ip_address, port, io_address, serial_number) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$name, $ip, $port, $io, $serial]); + $message = "Device added successfully!"; + } + } + if (isset($_POST['edit_biometric_device'])) { + $id = (int)$_POST['id']; + $name = $_POST['device_name'] ?? ''; + $ip = $_POST['ip_address'] ?? ''; + $port = (int)($_POST['port'] ?? 4370); + $io = $_POST['io_address'] ?? ''; + $serial = $_POST['serial_number'] ?? ''; + if ($id && $name && $ip) { + $stmt = db()->prepare("UPDATE hr_biometric_devices SET device_name = ?, ip_address = ?, port = ?, io_address = ?, serial_number = ? WHERE id = ?"); + $stmt->execute([$name, $ip, $port, $io, $serial, $id]); + $message = "Device updated successfully!"; + } + } + if (isset($_POST['delete_biometric_device'])) { + $id = (int)$_POST['id']; + if ($id) { + $stmt = db()->prepare("DELETE FROM hr_biometric_devices WHERE id = ?"); + $stmt->execute([$id]); + $message = "Device deleted successfully!"; + } + } + + if (isset($_POST['pull_biometric_data'])) { + $devices = db()->query("SELECT * FROM hr_biometric_devices WHERE status = 'active'")->fetchAll(); + if (empty($devices)) { + $message = "No active biometric devices found to pull data from."; + } else { + // Simulation of pulling data from multiple devices + $employees = db()->query("SELECT id, biometric_id FROM hr_employees WHERE biometric_id IS NOT NULL")->fetchAll(); + $pulled_count = 0; + $device_count = 0; + $date = date('Y-m-d'); + + foreach ($devices as $device) { + $device_pulled = 0; + foreach ($employees as $emp) { + // Randomly simulate logs for each employee for this device + if (rand(0, 1)) { + $check_in = $date . ' ' . str_pad((string)rand(7, 9), 2, '0', STR_PAD_LEFT) . ':' . str_pad((string)rand(0, 59), 2, '0', STR_PAD_LEFT) . ':00'; + $check_out = $date . ' ' . str_pad((string)rand(16, 18), 2, '0', STR_PAD_LEFT) . ':' . str_pad((string)rand(0, 59), 2, '0', STR_PAD_LEFT) . ':00'; + + // Log check-in + $stmt = db()->prepare("INSERT INTO hr_biometric_logs (biometric_id, device_id, employee_id, timestamp, type) VALUES (?, ?, ?, ?, 'check_in')"); + $stmt->execute([$emp['biometric_id'], $device['id'], $emp['id'], $check_in]); + + // Log check-out + $stmt = db()->prepare("INSERT INTO hr_biometric_logs (biometric_id, device_id, employee_id, timestamp, type) VALUES (?, ?, ?, ?, 'check_out')"); + $stmt->execute([$emp['biometric_id'], $device['id'], $emp['id'], $check_out]); + + $device_pulled += 2; + $pulled_count += 2; + + $in_time = date('H:i:s', strtotime($check_in)); + $out_time = date('H:i:s', strtotime($check_out)); + + // Update attendance record (earliest in, latest out) + $stmt = db()->prepare("INSERT INTO hr_attendance (employee_id, attendance_date, status, clock_in, clock_out) + VALUES (?, ?, 'present', ?, ?) + ON DUPLICATE KEY UPDATE status = 'present', + clock_in = IF(clock_in IS NULL OR ? < clock_in, ?, clock_in), + clock_out = IF(clock_out IS NULL OR ? > clock_out, ?, clock_out)"); + $stmt->execute([$emp['id'], $date, $in_time, $out_time, $in_time, $in_time, $out_time, $out_time]); + } + } + db()->prepare("UPDATE hr_biometric_devices SET last_sync = CURRENT_TIMESTAMP WHERE id = ?")->execute([$device['id']]); + $device_count++; + } + $message = "Successfully synced $device_count devices and pulled $pulled_count records."; + } + } + + if (isset($_POST['test_device_connection'])) { + $id = (int)$_POST['id']; + $device = db()->prepare("SELECT * FROM hr_biometric_devices WHERE id = ?"); + $device->execute([$id]); + $d = $device->fetch(); + if ($d) { + // Simulated connection check + $message = "Connection to device '{$d['device_name']}' ({$d['ip_address']}) was successful! (Simulated)"; + } + } } @@ -1402,6 +1682,9 @@ if ($page === 'export') { $data['categories'] = db()->query("SELECT * FROM stock_categories ORDER BY name_en ASC")->fetchAll(); $data['units'] = db()->query("SELECT * FROM stock_units ORDER BY name_en ASC")->fetchAll(); $data['suppliers'] = db()->query("SELECT * FROM customers WHERE type = 'supplier' ORDER BY name ASC")->fetchAll(); +$data['accounts'] = db()->query("SELECT * FROM acc_accounts ORDER BY code ASC")->fetchAll(); +$data['customers_list'] = db()->query("SELECT * FROM customers WHERE type = 'customer' ORDER BY name ASC")->fetchAll(); +$customers = $data['customers_list']; // For backward compatibility in some modals $settings_raw = db()->query("SELECT * FROM settings")->fetchAll(); $data['settings'] = []; @@ -1651,6 +1934,57 @@ switch ($page) { $data['expenses'] = $stmt->fetchAll(); $data['expense_categories'] = db()->query("SELECT * FROM expense_categories ORDER BY name_en ASC")->fetchAll(); break; + case 'accounting': + $data['journal_entries'] = db()->query("SELECT je.*, + (SELECT SUM(debit) FROM acc_ledger WHERE journal_entry_id = je.id) as total_debit + FROM acc_journal_entries je + ORDER BY je.entry_date DESC, je.id DESC LIMIT 100")->fetchAll(); + $data['accounts'] = db()->query("SELECT * FROM acc_accounts ORDER BY code ASC")->fetchAll(); + + if (isset($_GET['action']) && $_GET['action'] === 'get_entry_details') { + header('Content-Type: application/json'); + $id = (int)$_GET['id']; + $stmt = db()->prepare("SELECT l.*, a.name_en, a.code FROM acc_ledger l JOIN acc_accounts a ON l.account_id = a.id WHERE l.journal_entry_id = ?"); + $stmt->execute([$id]); + echo json_encode($stmt->fetchAll()); + exit; + } + + if (isset($_GET['view']) && $_GET['view'] === 'trial_balance') { + $data['trial_balance'] = db()->query("SELECT a.code, a.name_en, SUM(l.debit) as total_debit, SUM(l.credit) as total_credit + FROM acc_accounts a + LEFT JOIN acc_ledger l ON a.id = l.account_id + GROUP BY a.id + HAVING total_debit > 0 OR total_credit > 0 + ORDER BY a.code ASC")->fetchAll(); + } + + if (isset($_GET['view']) && $_GET['view'] === 'profit_loss') { + $data['revenue_accounts'] = db()->query("SELECT code, name_en, name_ar FROM acc_accounts WHERE type = 'revenue' AND parent_id IS NOT NULL ORDER BY code ASC")->fetchAll(); + $data['expense_accounts'] = db()->query("SELECT code, name_en, name_ar FROM acc_accounts WHERE type = 'expense' AND parent_id IS NOT NULL ORDER BY code ASC")->fetchAll(); + } + + if (isset($_GET['view']) && $_GET['view'] === 'balance_sheet') { + $data['asset_accounts'] = db()->query("SELECT code, name_en, name_ar FROM acc_accounts WHERE type = 'asset' AND parent_id IS NOT NULL ORDER BY code ASC")->fetchAll(); + $data['liability_accounts'] = db()->query("SELECT code, name_en, name_ar FROM acc_accounts WHERE type = 'liability' AND parent_id IS NOT NULL ORDER BY code ASC")->fetchAll(); + $data['equity_accounts'] = db()->query("SELECT code, name_en, name_ar FROM acc_accounts WHERE type = 'equity' AND parent_id IS NOT NULL ORDER BY code ASC")->fetchAll(); + } + + if (isset($_GET['view']) && $_GET['view'] === 'vat_report') { + $start = $_GET['start_date'] ?? date('Y-m-01'); + $end = $_GET['end_date'] ?? date('Y-m-d'); + $data['vat_report'] = getVatReport($start, $end); + $data['start_date'] = $start; + $data['end_date'] = $end; + } + + if (isset($_GET['view']) && $_GET['view'] === 'coa') { + $data['coa'] = db()->query("SELECT a.*, p.name_en as parent_name + FROM acc_accounts a + LEFT JOIN acc_accounts p ON a.parent_id = p.id + ORDER BY a.code ASC")->fetchAll(); + } + break; case 'expense_report': $start_date = $_GET['start_date'] ?? date('Y-m-01'); $end_date = $_GET['end_date'] ?? date('Y-m-d'); @@ -1711,6 +2045,9 @@ switch ($page) { $data['payroll'] = db()->query("SELECT p.*, e.name as emp_name FROM hr_payroll p JOIN hr_employees e ON p.employee_id = e.id WHERE p.payroll_month = $month AND p.payroll_year = $year ORDER BY p.id DESC")->fetchAll(); $data['employees'] = db()->query("SELECT id, name, salary FROM hr_employees WHERE status = 'active' ORDER BY name ASC")->fetchAll(); break; + case 'devices': + $data['devices'] = db()->query("SELECT * FROM hr_biometric_devices ORDER BY id DESC")->fetchAll(); + break; default: $data['customers'] = db()->query("SELECT * FROM customers WHERE type = 'customer' ORDER BY id DESC LIMIT 5")->fetchAll(); // Dashboard stats @@ -1800,20 +2137,40 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; - -