diff --git a/db/migrations/20260218_pos_payments.sql b/db/migrations/20260218_pos_payments.sql new file mode 100644 index 0000000..40a22c5 --- /dev/null +++ b/db/migrations/20260218_pos_payments.sql @@ -0,0 +1,12 @@ +-- Create pos_payments table to support split payments and detailed reporting +CREATE TABLE IF NOT EXISTS pos_payments ( + id INT AUTO_INCREMENT PRIMARY KEY, + transaction_id INT NOT NULL, + payment_method VARCHAR(50) NOT NULL, + amount DECIMAL(15, 3) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (transaction_id) REFERENCES pos_transactions(id) ON DELETE CASCADE +); + +-- Add register_session_id to pos_transactions if missing (already exists but for idempotency) +-- ALTER TABLE pos_transactions ADD COLUMN IF NOT EXISTS register_session_id INT NULL; diff --git a/index.php b/index.php index 6875128..8823d98 100644 --- a/index.php +++ b/index.php @@ -137,6 +137,121 @@ if (isset($_GET['action']) && $_GET['action'] === 'logout') { exit; } +// --- POS AJAX Handlers --- +if (isset($_GET['action']) || isset($_POST['action'])) { + $action = $_GET['action'] ?? $_POST['action'] ?? ''; + + if ($action === 'validate_discount') { + header('Content-Type: application/json'); + $code = $_GET['code'] ?? ''; + $stmt = db()->prepare("SELECT * FROM discount_codes WHERE code = ? AND status = 'active' AND (expiry_date IS NULL OR expiry_date >= CURDATE())"); + $stmt->execute([$code]); + $discount = $stmt->fetch(PDO::FETCH_ASSOC); + if ($discount) { + echo json_encode(['success' => true, 'discount' => $discount]); + } else { + echo json_encode(['success' => false, 'error' => 'Invalid or expired discount code']); + } + exit; + } + + if ($action === 'get_held_carts') { + header('Content-Type: application/json'); + $stmt = db()->query("SELECT h.*, c.name as customer_name FROM pos_held_carts h LEFT JOIN customers c ON h.customer_id = c.id ORDER BY h.created_at DESC"); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + exit; + } + + if ($action === 'hold_pos_cart') { + header('Content-Type: application/json'); + $name = $_POST['cart_name'] ?? 'Untitled Cart'; + $items = $_POST['items'] ?? '[]'; + $customer_id = !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null; + $stmt = db()->prepare("INSERT INTO pos_held_carts (cart_name, items_json, customer_id) VALUES (?, ?, ?)"); + $stmt->execute([$name, $items, $customer_id]); + echo json_encode(['success' => true]); + exit; + } + + if ($action === 'delete_held_cart') { + header('Content-Type: application/json'); + $id = (int)$_POST['id']; + $stmt = db()->prepare("DELETE FROM pos_held_carts WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['success' => true]); + exit; + } + + if ($action === 'save_pos_transaction') { + header('Content-Type: application/json'); + $db = db(); + try { + $db->beginTransaction(); + + $customer_id = !empty($_POST['customer_id']) ? (int)$_POST['customer_id'] : null; + $payments = json_decode($_POST['payments'] ?? '[]', true); + $items = json_decode($_POST['items'] ?? '[]', true); + $total_amount = (float)($_POST['total_amount'] ?? 0); + $discount_code_id = !empty($_POST['discount_code_id']) ? (int)$_POST['discount_code_id'] : null; + $discount_amount = (float)($_POST['discount_amount'] ?? 0); + $loyalty_redeemed = (float)($_POST['loyalty_redeemed'] ?? 0); + $net_amount = $total_amount - $discount_amount - $loyalty_redeemed; + + $transaction_no = 'POS-' . time() . rand(10, 99); + $session_id = $_SESSION['register_session_id'] ?? null; + + // Insert Transaction + $stmt = $db->prepare("INSERT INTO pos_transactions (transaction_no, customer_id, total_amount, discount_code_id, discount_amount, loyalty_points_redeemed, net_amount, register_session_id, created_by, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'completed')"); + $stmt->execute([$transaction_no, $customer_id, $total_amount, $discount_code_id, $discount_amount, $loyalty_redeemed, $net_amount, $session_id, $_SESSION['user_id']]); + $transaction_id = (int)$db->lastInsertId(); + + // Insert Items & Update Stock + $stmtItem = $db->prepare("INSERT INTO pos_items (transaction_id, product_id, quantity, unit_price, subtotal) VALUES (?, ?, ?, ?, ?)"); + $stmtStock = $db->prepare("UPDATE stock_items SET stock_quantity = stock_quantity - ? WHERE id = ?"); + + foreach ($items as $item) { + $sub = (float)$item['price'] * (float)$item['qty']; + $stmtItem->execute([$transaction_id, $item['id'], $item['qty'], $item['price'], $sub]); + $stmtStock->execute([$item['qty'], $item['id']]); + } + + // Insert Payments + $stmtPay = $db->prepare("INSERT INTO pos_payments (transaction_id, payment_method, amount) VALUES (?, ?, ?)"); + foreach ($payments as $p) { + $stmtPay->execute([$transaction_id, $p['method'], $p['amount']]); + } + + // Update Loyalty Points if customer exists + if ($customer_id) { + // Earn points + $points_earned = floor($net_amount); + $stmtPoints = $db->prepare("UPDATE customers SET loyalty_points = loyalty_points - ? + ? WHERE id = ?"); + $stmtPoints->execute([$loyalty_redeemed * 100, $points_earned, $customer_id]); + + // Record transactions + if ($points_earned > 0) { + $db->prepare("INSERT INTO loyalty_transactions (customer_id, transaction_id, points_change, transaction_type, description) VALUES (?, ?, ?, 'earned', ?)") + ->execute([$customer_id, $transaction_id, $points_earned, "Earned from POS order #$transaction_no"]); + } + if ($loyalty_redeemed > 0) { + $db->prepare("INSERT INTO loyalty_transactions (customer_id, transaction_id, points_change, transaction_type, description) VALUES (?, ?, ?, 'redeemed', ?)") + ->execute([$customer_id, $transaction_id, -$loyalty_redeemed * 100, "Redeemed for POS order #$transaction_no"]); + } + + // Update transaction with points earned + $db->prepare("UPDATE pos_transactions SET loyalty_points_earned = ? WHERE id = ?")->execute([$points_earned, $transaction_id]); + } + + $db->commit(); + echo json_encode(['success' => true, 'invoice_id' => $transaction_id, 'transaction_no' => $transaction_no]); + } catch (Exception $e) { + $db->rollBack(); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } + exit; + } +} + // Redirect to login if not authenticated if (!isset($_SESSION['user_id'])) { ?> @@ -535,6 +650,44 @@ if (isset($_POST['add_hr_department'])) { } } + // --- POS Devices Handlers --- + if (isset($_POST['add_pos_device'])) { + $name = $_POST['device_name'] ?? ''; + $type = $_POST['device_type'] ?? 'scale'; + $conn = $_POST['connection_type'] ?? 'usb'; + $ip = $_POST['ip_address'] ?? ''; + $port = $_POST['port'] ? (int)$_POST['port'] : null; + $baud = $_POST['baud_rate'] ? (int)$_POST['baud_rate'] : null; + if ($name) { + $stmt = db()->prepare("INSERT INTO pos_devices (device_name, device_type, connection_type, ip_address, port, baud_rate) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->execute([$name, $type, $conn, $ip, $port, $baud]); + $message = "Device added successfully!"; + } + } + if (isset($_POST['edit_pos_device'])) { + $id = (int)$_POST['id']; + $name = $_POST['device_name'] ?? ''; + $type = $_POST['device_type'] ?? 'scale'; + $conn = $_POST['connection_type'] ?? 'usb'; + $ip = $_POST['ip_address'] ?? ''; + $port = $_POST['port'] ? (int)$_POST['port'] : null; + $baud = $_POST['baud_rate'] ? (int)$_POST['baud_rate'] : null; + $status = $_POST['status'] ?? 'active'; + if ($id && $name) { + $stmt = db()->prepare("UPDATE pos_devices SET device_name = ?, device_type = ?, connection_type = ?, ip_address = ?, port = ?, baud_rate = ?, status = ? WHERE id = ?"); + $stmt->execute([$name, $type, $conn, $ip, $port, $baud, $status, $id]); + $message = "Device updated successfully!"; + } + } + if (isset($_POST['delete_pos_device'])) { + $id = (int)$_POST['id']; + if ($id) { + $stmt = db()->prepare("DELETE FROM pos_devices WHERE id = ?"); + $stmt->execute([$id]); + $message = "Device deleted successfully!"; + } + } + if (isset($_POST['update_profile'])) { $id = $_SESSION['user_id']; $username = $_POST['username'] ?? ''; @@ -606,6 +759,76 @@ if (isset($_POST['add_hr_department'])) { } } + // --- Cash Register & Session Handlers --- + if (isset($_POST['add_cash_register'])) { + $name = $_POST['name'] ?? ''; + if ($name) { + $stmt = db()->prepare("INSERT INTO cash_registers (name) VALUES (?)"); + $stmt->execute([$name]); + $message = "Cash Register added successfully!"; + } + } + if (isset($_POST['edit_cash_register'])) { + $id = (int)$_POST['id']; + $name = $_POST['name'] ?? ''; + $status = $_POST['status'] ?? 'active'; + if ($id && $name) { + $stmt = db()->prepare("UPDATE cash_registers SET name = ?, status = ? WHERE id = ?"); + $stmt->execute([$name, $status, $id]); + $message = "Cash Register updated successfully!"; + } + } + if (isset($_POST['delete_cash_register'])) { + $id = (int)$_POST['id']; + if ($id) { + $stmt = db()->prepare("DELETE FROM cash_registers WHERE id = ?"); + $stmt->execute([$id]); + $message = "Cash Register deleted successfully!"; + } + } + + if (isset($_POST['open_register'])) { + $register_id = (int)$_POST['register_id']; + $user_id = $_SESSION['user_id']; + $opening_balance = (float)$_POST['opening_balance']; + + // Check if user already has an open session + $check = db()->prepare("SELECT id FROM register_sessions WHERE user_id = ? AND status = 'open'"); + $check->execute([$user_id]); + if ($check->fetch()) { + $message = "Error: You already have an open register session."; + } else { + $stmt = db()->prepare("INSERT INTO register_sessions (register_id, user_id, opening_balance, status) VALUES (?, ?, ?, 'open')"); + $stmt->execute([$register_id, $user_id, $opening_balance]); + $_SESSION['register_session_id'] = db()->lastInsertId(); + $message = "Register opened successfully!"; + } + } + + if (isset($_POST['close_register'])) { + $session_id = (int)$_POST['session_id']; + $cash_in_hand = (float)$_POST['cash_in_hand']; + $notes = $_POST['notes'] ?? ''; + + // Calculate expected closing balance + // Opening + Sum of POS Transactions (Cash) - Any cash outflows (if any) + $session = db()->prepare("SELECT opening_balance FROM register_sessions WHERE id = ?"); + $session->execute([$session_id]); + $opening = (float)$session->fetchColumn(); + + $sales = db()->prepare("SELECT SUM(p.amount) FROM pos_payments p JOIN pos_transactions t ON p.transaction_id = t.id WHERE t.register_session_id = ? AND t.status = 'completed' AND p.payment_method = 'cash'"); + $sales->execute([$session_id]); + $cash_sales = (float)$sales->fetchColumn(); + + $expected = $opening + $cash_sales; + + $stmt = db()->prepare("UPDATE register_sessions SET closing_balance = ?, cash_in_hand = ?, closed_at = CURRENT_TIMESTAMP, status = 'closed', notes = ? WHERE id = ?"); + $stmt->execute([$expected, $cash_in_hand, $notes, $session_id]); + + unset($_SESSION['register_session_id']); + $message = "Register closed successfully!"; + } + // Routing & Data Fetching $page = $_GET['page'] ?? 'dashboard'; @@ -642,8 +865,11 @@ $page_permissions = [ 'hr_payroll' => 'hr_view', 'role_groups' => 'users_view', 'users' => 'users_view', + 'scale_devices' => 'users_view', 'backups' => 'users_view', 'logs' => 'users_view', + 'cash_registers' => 'users_view', + 'register_sessions' => 'pos_view', ]; if (isset($page_permissions[$page]) && !can($page_permissions[$page])) { @@ -1189,6 +1415,30 @@ switch ($page) { case 'devices': $data['devices'] = db()->query("SELECT * FROM hr_biometric_devices ORDER BY id DESC")->fetchAll(); break; + case 'scale_devices': + $data['scale_devices'] = db()->query("SELECT * FROM pos_devices ORDER BY id DESC")->fetchAll(); + break; + case 'cash_registers': + $data['cash_registers'] = db()->query("SELECT * FROM cash_registers ORDER BY id DESC")->fetchAll(); + break; + case 'register_sessions': + $where = ["1=1"]; + $params = []; + if (!can('users_view')) { + $where[] = "s.user_id = ?"; + $params[] = $_SESSION['user_id']; + } + $whereSql = implode(" AND ", $where); + $stmt = db()->prepare("SELECT s.*, r.name as register_name, u.username + FROM register_sessions s + JOIN cash_registers r ON s.register_id = r.id + JOIN users u ON s.user_id = u.id + WHERE $whereSql + ORDER BY s.id DESC"); + $stmt->execute($params); + $data['sessions'] = $stmt->fetchAll(); + $data['cash_registers'] = db()->query("SELECT * FROM cash_registers WHERE status = 'active'")->fetchAll(); + break; default: $data['customers'] = db()->query("SELECT * FROM customers WHERE type = 'customer' ORDER BY id DESC LIMIT 5")->fetchAll(); // Dashboard stats @@ -1465,6 +1715,15 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; Users + + Cash Registers + + + Register Sessions + + + Devices + Backups @@ -1521,6 +1780,9 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; 'users' => ['en' => 'User Management', 'ar' => 'إدارة المستخدمين'], 'backups' => ['en' => 'Database Backups', 'ar' => 'نسخ قاعدة البيانات'], 'role_groups' => ['en' => 'Role Groups', 'ar' => 'مجموعات الأدوار'], + 'scale_devices' => ['en' => 'POS Devices', 'ar' => 'أجهزة نقاط البيع'], + 'cash_registers' => ['en' => 'Cash Registers', 'ar' => 'خزائن الكاشير'], + 'register_sessions' => ['en' => 'Register Sessions', 'ar' => 'جلسات الكاشير'], ]; $currTitle = $titles[$page] ?? $titles['dashboard']; ?> @@ -5174,6 +5436,188 @@ $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Accounting System'; + +
| Device Name | +Type | +Connection | +Details | +Status | +Actions | +
|---|---|---|---|---|---|
|
+ = htmlspecialchars($d['device_name']) ?>
+ |
+ + = $d['device_type'] ?> + | ++ = $d['connection_type'] ?> + | ++ + = htmlspecialchars((string)$d['ip_address']) ?>:= $d['port'] ?> + + Baud: = $d['baud_rate'] ?> + + USB Interface + + | ++ = $d['status'] ?> + | ++ + + | +
Define your shop counters and registers.
+| ID | +Name | +Status | +Created At | +Actions | +
|---|---|---|---|---|
| #= $r['id'] ?> | += htmlspecialchars($r['name']) ?> | ++ + = ucfirst($r['status']) ?> + + | += $r['created_at'] ?> | +
+
+
+
+
+
+
+
+
+
+ |
+
Manage daily opening and closing of cash registers.
+| Register | +Cashier | +Opened At | +Closed At | +Opening Bal. | +Expected Bal. | +Cash in Hand | +Difference | +Status | +Report | +
|---|---|---|---|---|---|---|---|---|---|
| = htmlspecialchars($s['register_name']) ?> | += htmlspecialchars($s['username']) ?> | += $s['opened_at'] ?> | += $s['closed_at'] ?? '---' ?> | +OMR = number_format((float)$s['opening_balance'], 3) ?> | +OMR = number_format((float)($s['closing_balance'] ?? 0), 3) ?> | +OMR = number_format((float)($s['cash_in_hand'] ?? 0), 3) ?> | ++ 0 ? 'text-info' : 'text-danger'); + ?> + OMR = number_format($diff, 3) ?> + --- + | ++ + = ucfirst($s['status']) ?> + + | +
+
+
+
+
+
+
+
+
+ |
+