true,'data'=>$d]); exit; } function json_err($m) { header('Content-Type: application/json'); echo json_encode(['success'=>false,'data'=>['message'=>$m]]); exit; } class DB { private $p; function __construct() { $this->p = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [PDO::ATTR_ERRMODE=>PDO::ERRMODE_EXCEPTION]); } function q($sql) { return $this->p->exec($sql); } function val($sql, $pr=[]) { $s=$this->p->prepare($sql); $s->execute($pr); return $s->fetchColumn(); } function row($sql, $pr=[]) { $s=$this->p->prepare($sql); $s->execute($pr); return $s->fetch(PDO::FETCH_ASSOC); } function all($sql, $pr=[]) { $s=$this->p->prepare($sql); $s->execute($pr); return $s->fetchAll(PDO::FETCH_ASSOC); } function ins($t, $d) { $c=implode(',',array_keys($d)); $v=implode(',',array_fill(0,count($d),'?')); $s=$this->p->prepare("INSERT INTO $t ($c) VALUES ($v)"); $s->execute(array_values($d)); return $this->p->lastInsertId(); } function upd($t, $d, $w) { $s=implode(',',array_map(fn($k)=>"$k=?",array_keys($d))); $wh=implode(' AND ',array_map(fn($k)=>"$k=?",array_keys($w))); $st=$this->p->prepare("UPDATE $t SET $s WHERE $wh"); $st->execute(array_merge(array_values($d),array_values($w))); } function del($t, $w) { $wh=implode(' AND ',array_map(fn($k)=>"$k=?",array_keys($w))); $s=$this->p->prepare("DELETE FROM $t WHERE $wh"); $s->execute(array_values($w)); } } // ============ AUTH ============ session_start(); if (isset($_GET['logout'])) { $_SESSION['acc_auth']=false; header('Location: ./'); exit; } if (isset($_POST['acc_password']) && !isset($_POST['admin_action'])) { if ($_POST['acc_password'] === ACC_PASSWORD) $_SESSION['acc_auth'] = true; } $auth = !empty($_SESSION['acc_auth']); // ============ AJAX ============ if (isset($_POST['admin_action'])) { if (!$auth) json_err('Not authenticated'); try { $db = new DB(); } catch(Exception $e) { json_err('DB error: '.$e->getMessage()); } $act = sanitize($_POST['admin_action']); switch($act) { case 'acc_setup': $db->q("CREATE TABLE IF NOT EXISTS acc_settings(id INT PRIMARY KEY AUTO_INCREMENT,company_name VARCHAR(200) DEFAULT 'DigitalBits (Pty) Ltd',registration_no VARCHAR(50),tax_number VARCHAR(20),address TEXT,city VARCHAR(100),province VARCHAR(50) DEFAULT 'Gauteng',postal_code VARCHAR(10),phone VARCHAR(20),email VARCHAR(100),bank_name VARCHAR(100) DEFAULT 'Capitec Business',bank_account VARCHAR(30),bank_branch VARCHAR(20),fy_start DATE DEFAULT '2025-03-01',fy_end DATE DEFAULT '2026-02-28',invoice_prefix VARCHAR(10) DEFAULT 'DB',next_invoice_no INT DEFAULT 1001,logo_path VARCHAR(200),created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_accounts(id INT PRIMARY KEY AUTO_INCREMENT,code VARCHAR(10) NOT NULL UNIQUE,name VARCHAR(200) NOT NULL,account_type ENUM('asset','liability','equity','income','cogs','expense') NOT NULL,sub_type VARCHAR(50),is_system TINYINT(1) DEFAULT 0,is_active TINYINT(1) DEFAULT 1,description VARCHAR(500),created_at DATETIME DEFAULT CURRENT_TIMESTAMP,INDEX idx_type(account_type),INDEX idx_code(code))ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_journals(id INT PRIMARY KEY AUTO_INCREMENT,journal_no VARCHAR(20) NOT NULL,journal_date DATE NOT NULL,description VARCHAR(500),source_type ENUM('manual','opening','invoice','receipt','payment','expense','credit_note') DEFAULT 'manual',source_id INT,period VARCHAR(7),is_posted TINYINT(1) DEFAULT 1,created_at DATETIME DEFAULT CURRENT_TIMESTAMP,INDEX idx_date(journal_date),INDEX idx_period(period))ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_journal_lines(id INT PRIMARY KEY AUTO_INCREMENT,journal_id INT NOT NULL,account_code VARCHAR(10) NOT NULL,description VARCHAR(500),debit DECIMAL(12,2) DEFAULT 0.00,credit DECIMAL(12,2) DEFAULT 0.00,INDEX idx_journal(journal_id),INDEX idx_account(account_code))ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_customers(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(200) NOT NULL,email VARCHAR(200),phone VARCHAR(20),address VARCHAR(500),account_code VARCHAR(10) DEFAULT '1100',balance DECIMAL(12,2) DEFAULT 0.00,is_active TINYINT(1) DEFAULT 1,notes TEXT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_suppliers(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(200) NOT NULL,email VARCHAR(200),phone VARCHAR(20),address VARCHAR(500),account_code VARCHAR(10) DEFAULT '2000',balance DECIMAL(12,2) DEFAULT 0.00,is_active TINYINT(1) DEFAULT 1,notes TEXT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_invoices(id INT PRIMARY KEY AUTO_INCREMENT,invoice_no VARCHAR(20) NOT NULL,invoice_date DATE NOT NULL,due_date DATE NOT NULL,customer_id INT NOT NULL,subtotal DECIMAL(12,2) NOT NULL,total DECIMAL(12,2) NOT NULL,amount_paid DECIMAL(12,2) DEFAULT 0.00,status ENUM('draft','sent','paid','partial','overdue','cancelled') DEFAULT 'draft',notes TEXT,journal_id INT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_invoice_lines(id INT PRIMARY KEY AUTO_INCREMENT,invoice_id INT NOT NULL,description VARCHAR(500) NOT NULL,quantity DECIMAL(10,2) DEFAULT 1,unit_price DECIMAL(12,2) NOT NULL,line_total DECIMAL(12,2) NOT NULL,stock_item_id INT,account_code VARCHAR(10) DEFAULT '4000',INDEX idx_inv(invoice_id))ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_expenses(id INT PRIMARY KEY AUTO_INCREMENT,reference VARCHAR(50),expense_date DATE NOT NULL,supplier_id INT,account_code VARCHAR(10) NOT NULL,description VARCHAR(500),amount DECIMAL(12,2) NOT NULL,payment_method ENUM('bank','cash','card','eft') DEFAULT 'eft',is_paid TINYINT(1) DEFAULT 0,receipt_path VARCHAR(300),journal_id INT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_bank_transactions(id INT PRIMARY KEY AUTO_INCREMENT,transaction_date DATE NOT NULL,bank_account_code VARCHAR(10) DEFAULT '1000',type ENUM('receipt','payment','transfer','interest','fee') NOT NULL,reference VARCHAR(100),description VARCHAR(500),amount DECIMAL(12,2) NOT NULL,customer_id INT,supplier_id INT,invoice_id INT,is_reconciled TINYINT(1) DEFAULT 0,bank_statement_ref VARCHAR(100),journal_id INT,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_stock_items(id INT PRIMARY KEY AUTO_INCREMENT,sku VARCHAR(50) NOT NULL UNIQUE,name VARCHAR(200) NOT NULL,category VARCHAR(50),description VARCHAR(500),cost_price DECIMAL(12,2) DEFAULT 0.00,selling_price DECIMAL(12,2) DEFAULT 0.00,quantity_on_hand INT DEFAULT 0,reorder_level INT DEFAULT 5,income_account VARCHAR(10) DEFAULT '4000',cogs_account VARCHAR(10) DEFAULT '5000',stock_account VARCHAR(10) DEFAULT '1300',is_active TINYINT(1) DEFAULT 1,created_at DATETIME DEFAULT CURRENT_TIMESTAMP)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $db->q("CREATE TABLE IF NOT EXISTS acc_stock_movements(id INT PRIMARY KEY AUTO_INCREMENT,stock_item_id INT NOT NULL,movement_date DATE NOT NULL,type ENUM('purchase','sale','adjustment','return','opening') NOT NULL,quantity INT NOT NULL,unit_cost DECIMAL(12,2),reference VARCHAR(100),notes VARCHAR(500),created_at DATETIME DEFAULT CURRENT_TIMESTAMP,INDEX idx_item(stock_item_id))ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); $cnt = $db->val("SELECT COUNT(*) FROM acc_accounts"); if (!$cnt) { $accs = [['1000','Capitec Business Account','asset','bank',1],['1010','Yoco Settlements','asset','bank',0],['1020','Petty Cash','asset','cash',0],['1100','Trade Debtors','asset','current',1],['1110','Other Debtors','asset','current',0],['1300','Inventory - Finished Goods','asset','current',0],['1310','Inventory - Raw Materials','asset','current',0],['1320','Inventory - Packaging','asset','current',0],['1400','Prepaid Expenses','asset','current',0],['1500','Equipment - Laser Engraver','asset','fixed',0],['1510','Equipment - 3D Printer','asset','fixed',0],['1520','Equipment - Cricut','asset','fixed',0],['1530','Computer Equipment','asset','fixed',0],['1590','Accumulated Depreciation','asset','fixed',0],['2000','Trade Creditors','liability','current',1],['2010','Other Creditors','liability','current',0],['2200','Income Tax Payable','liability','current',0],['2300','Accrued Expenses','liability','current',0],['3000','Members Capital','equity','capital',1],['3100','Retained Earnings','equity','retained',1],['3200','Drawings','equity','drawings',0],['3300','Current Year Earnings','equity','current',1],['4000','Sales - HeartLink','income','sales',0],['4010','Sales - PocketPic','income','sales',0],['4020','Sales - LightLink','income','sales',0],['4030','Sales - Accessories','income','sales',0],['4040','Sales - Custom/Corporate','income','sales',0],['4100','Subscription Revenue','income','recurring',0],['4200','Shipping Income','income','other',0],['4900','Other Income','income','other',0],['4910','Interest Received','income','other',0],['5000','COGS - HeartLink','cogs','direct',0],['5010','COGS - PocketPic','cogs','direct',0],['5020','COGS - LightLink','cogs','direct',0],['5100','NFC Tags & Chips','cogs','materials',0],['5110','Filament (PLA/Silk PLA)','cogs','materials',0],['5120','Packaging Materials','cogs','materials',0],['5130','Laser Consumables','cogs','materials',0],['5140','Shipping Costs (outbound)','cogs','delivery',0],['6000','Advertising & Marketing','expense','marketing',0],['6010','Website & Hosting','expense','technology',0],['6020','Domain Names & SSL','expense','technology',0],['6030','Payment Gateway Fees','expense','banking',0],['6040','Marketplace Fees','expense','marketing',0],['6100','Equipment Repairs','expense','operational',0],['6110','Electricity','expense','operational',0],['6120','Internet & Data','expense','technology',0],['6130','Phone & Communications','expense','technology',0],['6200','Software & Subscriptions','expense','technology',0],['6210','Cloud Services','expense','technology',0],['6300','Office Supplies','expense','admin',0],['6400','Transport & Fuel','expense','operational',0],['6410','Courier & Delivery','expense','operational',0],['6500','Insurance','expense','admin',0],['6600','Bank Charges','expense','banking',0],['6700','Accounting Fees','expense','professional',0],['6800','Depreciation','expense','depreciation',0],['6900','Sundry Expenses','expense','other',0]]; foreach($accs as $a) $db->ins('acc_accounts',['code'=>$a[0],'name'=>$a[1],'account_type'=>$a[2],'sub_type'=>$a[3],'is_system'=>$a[4]]); } if (!$db->val("SELECT COUNT(*) FROM acc_settings")) $db->ins('acc_settings',['company_name'=>'DigitalBits (Pty) Ltd','province'=>'Gauteng','bank_name'=>'Capitec Business','fy_start'=>'2025-03-01','fy_end'=>'2026-02-28']); json_ok(['message'=>'Accounting tables created & chart of accounts seeded','accounts'=>$cnt?'existed':'seeded']); break; case 'acc_get_coa': case 'acc_get_accounts': $t=sanitize($_POST['type']??'all'); $w=$t!=='all'?"WHERE account_type='".addslashes($t)."'":''; json_ok(['accounts'=>$db->all("SELECT * FROM acc_accounts $w ORDER BY code")]); break; case 'acc_add_account': case 'acc_save_account': $c=sanitize($_POST['code']??'');$n=sanitize($_POST['name']??'');$at=sanitize($_POST['account_type']??'');$st=sanitize($_POST['sub_type']??'');$eid=intval($_POST['edit_id']??0); if(!$c||!$n||!$at) json_err('Code, name, and type required'); if($eid) $db->upd('acc_accounts',['code'=>$c,'name'=>$n,'account_type'=>$at,'sub_type'=>$st],['id'=>$eid]); else { if($db->val("SELECT id FROM acc_accounts WHERE code=?",[$c])) json_err('Code already exists'); $db->ins('acc_accounts',['code'=>$c,'name'=>$n,'account_type'=>$at,'sub_type'=>$st]); } json_ok(['message'=>'Account saved']); break; case 'acc_delete_account': $id=intval($_POST['id']??$_POST['account_id']??0); $a=$db->row("SELECT * FROM acc_accounts WHERE id=?",[$id]); if(!$a) json_err('Not found'); if($a['is_system']) json_err('Cannot delete system account'); $used=$db->val("SELECT COUNT(*) FROM acc_journal_lines WHERE account_code=?",[$a['code']]); if($used) json_err("Has $used journal entries โ cannot delete"); $db->del('acc_accounts',['id'=>$id]); json_ok(['message'=>'Deleted']); break; case 'acc_post_journal': $dt=sanitize($_POST['date']??date('Y-m-d'));$desc=sanitize($_POST['description']??'');$src=sanitize($_POST['source_type']??'manual'); $lines=json_decode($_POST['lines']??'[]',true); if(!$desc) json_err('Description required'); if(empty($lines)||count($lines)<2) json_err('Need at least 2 lines'); $tdr=0;$tcr=0; foreach($lines as $l){$tdr+=floatval($l['debit']??0);$tcr+=floatval($l['credit']??0);} if(abs($tdr-$tcr)>0.01) json_err('Does not balance: DR '.number_format($tdr,2).' vs CR '.number_format($tcr,2)); $jnum=$db->val("SELECT COALESCE(MAX(CAST(SUBSTRING(journal_no,4) AS UNSIGNED)),0)+1 FROM acc_journals"); $jno='JNL'.str_pad($jnum,4,'0',STR_PAD_LEFT); $jid=$db->ins('acc_journals',['journal_no'=>$jno,'journal_date'=>$dt,'description'=>$desc,'source_type'=>$src,'period'=>date('Y-m',strtotime($dt))]); foreach($lines as $l) $db->ins('acc_journal_lines',['journal_id'=>$jid,'account_code'=>sanitize($l['account_code']),'description'=>sanitize($l['description']??''),'debit'=>floatval($l['debit']??0),'credit'=>floatval($l['credit']??0)]); json_ok(['message'=>"Journal $jno posted",'journal_no'=>$jno]); break; case 'acc_get_journals': $p=sanitize($_POST['period']??''); $w=$p?"WHERE period='".addslashes($p)."'":''; json_ok(['journals'=>$db->all("SELECT j.*,(SELECT SUM(debit) FROM acc_journal_lines WHERE journal_id=j.id) as total_debit,(SELECT COUNT(*) FROM acc_journal_lines WHERE journal_id=j.id) as line_count FROM acc_journals j $w ORDER BY j.journal_date DESC,j.id DESC LIMIT 200")]); break; case 'acc_get_journal': $jid=intval($_POST['journal_id']??0); $j=$db->row("SELECT * FROM acc_journals WHERE id=?",[$jid]); if(!$j) json_err('Not found'); json_ok(['journal'=>$j,'lines'=>$db->all("SELECT jl.*,a.name as account_name FROM acc_journal_lines jl LEFT JOIN acc_accounts a ON jl.account_code=a.code WHERE jl.journal_id=? ORDER BY jl.id",[$jid])]); break; case 'acc_delete_journal': $jid=intval($_POST['journal_id']??0); $db->del('acc_journal_lines',['journal_id'=>$jid]); $db->del('acc_journals',['id'=>$jid]); json_ok(['message'=>'Journal deleted']); break; case 'acc_trial_balance': $d=sanitize($_POST['as_at']??date('Y-m-d')); json_ok(['accounts'=>$db->all("SELECT a.code,a.name,a.account_type,COALESCE(SUM(jl.debit),0) as total_debit,COALESCE(SUM(jl.credit),0) as total_credit,COALESCE(SUM(jl.debit),0)-COALESCE(SUM(jl.credit),0) as balance FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date<='$d' GROUP BY a.code,a.name,a.account_type HAVING total_debit>0 OR total_credit>0 ORDER BY a.code"),'as_at'=>$d]); break; case 'acc_pnl': $f=sanitize($_POST['from']??date('Y-m-01'));$t=sanitize($_POST['to']??date('Y-m-d')); $inc=$db->all("SELECT a.code,a.name,COALESCE(SUM(jl.credit)-SUM(jl.debit),0) as amount FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date BETWEEN '$f' AND '$t' WHERE a.account_type='income' GROUP BY a.code,a.name HAVING amount!=0 ORDER BY a.code"); $cogs=$db->all("SELECT a.code,a.name,COALESCE(SUM(jl.debit)-SUM(jl.credit),0) as amount FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date BETWEEN '$f' AND '$t' WHERE a.account_type='cogs' GROUP BY a.code,a.name HAVING amount!=0 ORDER BY a.code"); $exp=$db->all("SELECT a.code,a.name,COALESCE(SUM(jl.debit)-SUM(jl.credit),0) as amount FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date BETWEEN '$f' AND '$t' WHERE a.account_type='expense' GROUP BY a.code,a.name HAVING amount!=0 ORDER BY a.code"); json_ok(['income'=>$inc,'cogs'=>$cogs,'expenses'=>$exp,'from'=>$f,'to'=>$t]); break; case 'acc_balance_sheet': $d=sanitize($_POST['as_at']??date('Y-m-d')); $assets=$db->all("SELECT a.code,a.name,a.sub_type,COALESCE(SUM(jl.debit)-SUM(jl.credit),0) as balance FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date<='$d' WHERE a.account_type='asset' GROUP BY a.code,a.name,a.sub_type HAVING balance!=0 ORDER BY a.code"); $liab=$db->all("SELECT a.code,a.name,a.sub_type,COALESCE(SUM(jl.credit)-SUM(jl.debit),0) as balance FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date<='$d' WHERE a.account_type='liability' GROUP BY a.code,a.name,a.sub_type HAVING balance!=0 ORDER BY a.code"); $eq=$db->all("SELECT a.code,a.name,a.sub_type,COALESCE(SUM(jl.credit)-SUM(jl.debit),0) as balance FROM acc_accounts a LEFT JOIN acc_journal_lines jl ON a.code=jl.account_code LEFT JOIN acc_journals j ON jl.journal_id=j.id AND j.journal_date<='$d' WHERE a.account_type='equity' GROUP BY a.code,a.name,a.sub_type HAVING balance!=0 ORDER BY a.code"); json_ok(['assets'=>$assets,'liabilities'=>$liab,'equity'=>$eq,'as_at'=>$d]); break; case 'acc_ledger': case 'acc_general_ledger': $c=sanitize($_POST['account_code']??'');$f=sanitize($_POST['from']??date('Y-m-01'));$t=sanitize($_POST['to']??date('Y-m-d')); if(!$c) json_err('Select an account'); $acc=$db->row("SELECT * FROM acc_accounts WHERE code=?",[$c]); $entries=$db->all("SELECT j.journal_no,j.journal_date,j.description as journal_desc,jl.description as line_desc,jl.debit,jl.credit FROM acc_journal_lines jl JOIN acc_journals j ON jl.journal_id=j.id WHERE jl.account_code=? AND j.journal_date BETWEEN ? AND ? ORDER BY j.journal_date,j.id",[$c,$f,$t]); $opening=$db->val("SELECT COALESCE(SUM(jl.debit)-SUM(jl.credit),0) FROM acc_journal_lines jl JOIN acc_journals j ON jl.journal_id=j.id WHERE jl.account_code=? AND j.journal_date",[$c,$f]); json_ok(['account'=>$acc,'entries'=>$entries,'opening_balance'=>floatval($opening),'from'=>$f,'to'=>$t]); break; case 'acc_save_settings': $fields=['company_name','registration_no','tax_number','address','city','province','postal_code','phone','email','bank_name','bank_account','bank_branch','invoice_prefix']; $data=[]; foreach($fields as $f) $data[$f]=sanitize($_POST[$f]??''); $db->upd('acc_settings',$data,['id'=>1]); json_ok(['message'=>'Settings saved']); break; case 'acc_get_settings': json_ok(['settings'=>$db->row("SELECT * FROM acc_settings LIMIT 1")]); break; default: json_err("Unknown action: $act"); } exit; } $url = strtok($_SERVER['REQUEST_URI'], '?'); ?>
| Code | Name | Type | Sub | Sys | Actions |
|---|---|---|---|---|---|
| Click Setup Tables to initialize | |||||