add accounting

This commit is contained in:
Flatlogic Bot 2026-02-18 02:09:29 +00:00
parent 990505d301
commit 722390af9b
9 changed files with 1597 additions and 20 deletions

101
api/biometric_sync.php Normal file
View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
// Biometric devices usually send data via POST
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!$data || !is_array($data)) {
echo json_encode(['success' => 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()]);
}

View File

@ -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');

View File

@ -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
);

View File

@ -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
);

View File

@ -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;

View File

@ -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');

View File

@ -0,0 +1,196 @@
<?php
/**
* Accounting Helper for Automatic Journal Entries
*/
function createJournalEntry($date, $description, $reference, $source_type, $source_id, $items) {
$db = db();
try {
$stmt = $db->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
];
}

1169
index.php

File diff suppressed because it is too large Load Diff

67
migrate_accounting.php Normal file
View File

@ -0,0 +1,67 @@
<?php
require_once 'db/config.php';
require_once 'includes/accounting_helper.php';
echo "Starting Accounting Migration...\n";
$db = db();
// Clear existing journal entries to avoid duplicates if re-running
// But only those that are linked to source types we are migrating
$db->exec("DELETE FROM acc_journal_entries WHERE source_type IN ('invoice', 'payment', 'expense', 'payroll', 'sales_return', 'purchase_return')");
echo "Cleared existing automatic journal entries.\n";
// 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']);
}
echo "Journaled Invoice #{$inv['id']} ({$inv['type']})\n";
}
// 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']);
}
echo "Journaled Payment #{$pay['id']} for {$pay['type']} Invoice\n";
}
// 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']);
echo "Journaled Expense #{$exp['id']}\n";
}
// 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']);
echo "Journaled Payroll #{$proll['id']} for {$proll['name']}\n";
}
// 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']);
echo "Journaled Sales Return #{$ret['id']}\n";
}
$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']);
echo "Journaled Purchase Return #{$ret['id']}\n";
}
echo "Migration completed successfully!\n";