true, 'data' => $data] : ['ok' => false, 'error' => $error] ); exit; } // All API calls require login if (!currentUserId()) respond(false, null, 'Not authenticated'); $action = $_GET['action'] ?? $_POST['action'] ?? ''; $uid = currentUserId(); try { $db = getDB(); // ── ACCOUNTS ───────────────────────────────────────────────────────────── if ($action === 'list_accounts') { $st = $db->prepare("SELECT * FROM accounts WHERE user_id=? ORDER BY name"); $st->execute([$uid]); respond(true, $st->fetchAll()); } if ($action === 'add_account') { $name = trim($_POST['name'] ?? ''); $currency = strtoupper(trim($_POST['currency'] ?? 'GBP')); if (!$name) respond(false, null, 'Name required'); $st = $db->prepare("INSERT INTO accounts (user_id, name, currency) VALUES (?,?,?)"); $st->execute([$uid, $name, $currency]); respond(true, ['id' => $db->lastInsertId()]); } if ($action === 'delete_account') { $id = (int)($_POST['id'] ?? 0); $db->prepare("DELETE FROM accounts WHERE id=? AND user_id=?")->execute([$id, $uid]); respond(true); } // ── RECURRING TRANSACTIONS ──────────────────────────────────────────────── if ($action === 'list_transactions') { $aid = (int)($_GET['account_id'] ?? 0); if ($aid) { $st = $db->prepare("SELECT t.*, a.currency FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE t.account_id=? AND a.user_id=? ORDER BY t.name"); $st->execute([$aid, $uid]); } else { $st = $db->prepare("SELECT t.*, a.currency FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? ORDER BY t.name"); $st->execute([$uid]); } respond(true, $st->fetchAll()); } if ($action === 'add_transaction') { $st = $db->prepare("INSERT INTO transactions (account_id,name,type,amount,start_date,frequency,interval_value,ends_on,notes,tags) VALUES (?,?,?,?,?,?,?,?,?,?)"); $st->execute([ (int)$_POST['account_id'], trim($_POST['name']), $_POST['type'], (float)$_POST['amount'], $_POST['start_date'], $_POST['frequency'], (int)($_POST['interval_value'] ?? 1), $_POST['ends_on'] ?: null, trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, ]); respond(true, ['id' => $db->lastInsertId()]); } if ($action === 'get_transaction') { $st = $db->prepare("SELECT t.* FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE t.id=? AND a.user_id=?"); $st->execute([(int)($_GET['id']??0), $uid]); respond(true, $st->fetch()); } if ($action === 'update_transaction') { $st = $db->prepare("UPDATE transactions SET name=?,type=?,amount=?,start_date=?,frequency=?,interval_value=?,ends_on=?,notes=?,tags=? WHERE id=?"); $st->execute([ trim($_POST['name']), $_POST['type'], (float)$_POST['amount'], $_POST['start_date'], $_POST['frequency'], (int)($_POST['interval_value'] ?? 1), $_POST['ends_on'] ?: null, trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, (int)$_POST['id'], ]); respond(true); } if ($action === 'delete_transaction') { $db->prepare("DELETE FROM transactions WHERE id=?")->execute([(int)($_POST['id']??0)]); respond(true); } // ── BALANCES ───────────────────────────────────────────────────────────── if ($action === 'list_balances') { $aid = (int)($_GET['account_id'] ?? 0); if ($aid) { $st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b JOIN accounts a ON a.id=b.account_id WHERE b.account_id=? AND a.user_id=? ORDER BY b.recorded_on DESC"); $st->execute([$aid, $uid]); } else { $st = $db->prepare("SELECT b.*,a.name AS account_name,a.currency FROM balances b JOIN accounts a ON a.id=b.account_id WHERE a.user_id=? ORDER BY b.recorded_on DESC"); $st->execute([$uid]); } respond(true, $st->fetchAll()); } if ($action === 'add_balance') { $st = $db->prepare("INSERT INTO balances (account_id,amount,type,recorded_on,notes) VALUES (?,?,?,?,?)"); $st->execute([ (int)$_POST['account_id'], (float)$_POST['amount'], $_POST['type'], $_POST['recorded_on'], trim($_POST['notes'] ?? '') ?: null, ]); respond(true, ['id' => $db->lastInsertId()]); } if ($action === 'delete_balance') { $db->prepare("DELETE FROM balances WHERE id=?")->execute([(int)($_POST['id']??0)]); respond(true); } // ── ONE-OFF TRANSACTIONS ────────────────────────────────────────────────── if ($action === 'list_oneoff') { $aid = (int)($_GET['account_id'] ?? 0); $limit = (int)($_GET['limit'] ?? 100); if ($aid) { $st = $db->prepare("SELECT o.*,a.currency FROM oneoff_transactions o JOIN accounts a ON a.id=o.account_id WHERE o.account_id=? AND a.user_id=? ORDER BY o.date DESC LIMIT $limit"); $st->execute([$aid, $uid]); } else { $st = $db->prepare("SELECT o.*,a.name AS account_name,a.currency FROM oneoff_transactions o JOIN accounts a ON a.id=o.account_id WHERE a.user_id=? ORDER BY o.date DESC LIMIT $limit"); $st->execute([$uid]); } respond(true, $st->fetchAll()); } if ($action === 'add_oneoff') { $st = $db->prepare("INSERT INTO oneoff_transactions (account_id,name,type,amount,date,notes,tags) VALUES (?,?,?,?,?,?,?)"); $st->execute([ (int)$_POST['account_id'], trim($_POST['name']), $_POST['type'], (float)$_POST['amount'], $_POST['date'], trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, ]); respond(true, ['id' => $db->lastInsertId()]); } if ($action === 'get_oneoff') { $st = $db->prepare("SELECT o.* FROM oneoff_transactions o JOIN accounts a ON a.id=o.account_id WHERE o.id=? AND a.user_id=?"); $st->execute([(int)($_GET['id']??0), $uid]); respond(true, $st->fetch()); } if ($action === 'update_oneoff') { $st = $db->prepare("UPDATE oneoff_transactions SET name=?,type=?,amount=?,date=?,notes=?,tags=? WHERE id=?"); $st->execute([ trim($_POST['name']), $_POST['type'], (float)$_POST['amount'], $_POST['date'], trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, (int)$_POST['id'], ]); respond(true); } if ($action === 'delete_oneoff') { $db->prepare("DELETE FROM oneoff_transactions WHERE id=?")->execute([(int)($_POST['id']??0)]); respond(true); } // ── DEBTS ───────────────────────────────────────────────────────────────── if ($action === 'list_debts') { $st = $db->prepare("SELECT * FROM debts WHERE user_id=? ORDER BY balance DESC"); $st->execute([$uid]); respond(true, $st->fetchAll()); } if ($action === 'add_debt') { $st = $db->prepare("INSERT INTO debts (user_id,name,type,balance,interest_rate,min_payment,due_day,credit_limit,notes,tags,opened_on,currency) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"); $st->execute([ $uid, trim($_POST['name']), $_POST['dtype'], (float)$_POST['balance'], (float)($_POST['interest_rate'] ?? 0), (float)($_POST['min_payment'] ?? 0), $_POST['due_day'] ? (int)$_POST['due_day'] : null, $_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null, trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, $_POST['opened_on'] ?: null, strtoupper(trim($_POST['currency'] ?? 'GBP')), ]); respond(true, ['id' => $db->lastInsertId()]); } if ($action === 'get_debt') { $st = $db->prepare("SELECT * FROM debts WHERE id=? AND user_id=?"); $st->execute([(int)($_GET['id']??0), $uid]); respond(true, $st->fetch()); } if ($action === 'update_debt') { $st = $db->prepare("UPDATE debts SET name=?,type=?,balance=?,interest_rate=?,min_payment=?,due_day=?, credit_limit=?,notes=?,tags=?,opened_on=?,currency=? WHERE id=? AND user_id=?"); $st->execute([ trim($_POST['name']), $_POST['dtype'], (float)$_POST['balance'], (float)($_POST['interest_rate'] ?? 0), (float)($_POST['min_payment'] ?? 0), $_POST['due_day'] ? (int)$_POST['due_day'] : null, $_POST['credit_limit'] ? (float)$_POST['credit_limit'] : null, trim($_POST['notes'] ?? '') ?: null, trim($_POST['tags'] ?? '') ?: null, $_POST['opened_on'] ?: null, strtoupper(trim($_POST['currency'] ?? 'GBP')), (int)$_POST['id'], $uid, ]); respond(true); } if ($action === 'delete_debt') { $db->prepare("DELETE FROM debts WHERE id=? AND user_id=?")->execute([(int)($_POST['id']??0), $uid]); respond(true); } // ── TRENDS ──────────────────────────────────────────────────────────────── if ($action === 'trends') { $aid = (int)($_GET['account_id'] ?? 0); $start = new DateTime('first day of this month'); $end = (clone $start)->modify('+12 months'); if ($aid) { $balSt = $db->prepare("SELECT amount, type FROM balances WHERE account_id=? ORDER BY recorded_on DESC LIMIT 1"); $balSt->execute([$aid]); } else { $balSt = $db->prepare("SELECT b.amount,b.type FROM balances b JOIN accounts a ON a.id=b.account_id WHERE a.user_id=? ORDER BY b.recorded_on DESC LIMIT 1"); $balSt->execute([$uid]); } $balRow = $balSt->fetch(); $runningBalance = $balRow ? ($balRow['type'] === 'negative' ? -(float)$balRow['amount'] : (float)$balRow['amount']) : 0.0; if ($aid) { $txSt = $db->prepare("SELECT t.* FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE t.account_id=? AND a.user_id=?"); $txSt->execute([$aid, $uid]); } else { $txSt = $db->prepare("SELECT t.* FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE a.user_id=?"); $txSt->execute([$uid]); } $txs = $txSt->fetchAll(); $points = []; $cursor = clone $start; while ($cursor <= $end) { foreach ($txs as $tx) { if (occursOn($tx, $cursor)) { $signed = $tx['type'] === 'income' ? (float)$tx['amount'] : -(float)$tx['amount']; $runningBalance += $signed; } } $points[] = ['date' => $cursor->format('M Y'), 'balance' => round($runningBalance, 2)]; $cursor->modify('+1 month'); } respond(true, $points); } // ── SUMMARY ─────────────────────────────────────────────────────────────── if ($action === 'summary') { $aid = (int)($_GET['account_id'] ?? 0); if ($aid) { $iSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='income'"); $iSt->execute([$aid]); $eSt = $db->prepare("SELECT COALESCE(SUM(amount),0) FROM transactions WHERE account_id=? AND type='expense'"); $eSt->execute([$aid]); } else { $iSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='income'"); $iSt->execute([$uid]); $eSt = $db->prepare("SELECT COALESCE(SUM(t.amount),0) FROM transactions t JOIN accounts a ON a.id=t.account_id WHERE a.user_id=? AND t.type='expense'"); $eSt->execute([$uid]); } $dSt = $db->prepare("SELECT COALESCE(SUM(balance),0) FROM debts WHERE user_id=?"); $dSt->execute([$uid]); respond(true, [ 'monthly_income' => (float)$iSt->fetchColumn(), 'monthly_expense' => (float)$eSt->fetchColumn(), 'total_debt' => (float)$dSt->fetchColumn(), ]); } // ── USER SETTINGS ───────────────────────────────────────────────────────── if ($action === 'change_password') { $ok = changePassword($uid, $_POST['current'] ?? '', $_POST['new'] ?? ''); $ok ? respond(true) : respond(false, null, 'Current password is incorrect.'); } if ($action === 'update_profile') { $dn = trim($_POST['display_name'] ?? ''); $db->prepare("UPDATE users SET display_name=? WHERE id=?")->execute([$dn, $uid]); $_SESSION['user']['display_name'] = $dn; respond(true); } // ── ADMIN ───────────────────────────────────────────────────────────────── // All admin actions re-verify admin status from DB for security if ($action === 'admin_list_users') { if (!isAdmin()) respond(false, null, 'Forbidden'); $st = $db->query(" SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.created_at, (SELECT COUNT(*) FROM accounts WHERE user_id = u.id) AS account_count FROM users u ORDER BY u.created_at ASC "); respond(true, $st->fetchAll()); } if ($action === 'admin_toggle_admin') { if (!isAdmin()) respond(false, null, 'Forbidden'); $id = (int)($_POST['id'] ?? 0); if ($id === $uid) respond(false, null, 'You cannot change your own admin status.'); $db->prepare("UPDATE users SET is_admin = 1 - is_admin WHERE id = ?")->execute([$id]); respond(true); } if ($action === 'admin_toggle_suspend') { if (!isAdmin()) respond(false, null, 'Forbidden'); $id = (int)($_POST['id'] ?? 0); if ($id === $uid) respond(false, null, 'You cannot suspend yourself.'); $db->prepare("UPDATE users SET is_suspended = 1 - is_suspended WHERE id = ?")->execute([$id]); respond(true); } if ($action === 'admin_delete_user') { if (!isAdmin()) respond(false, null, 'Forbidden'); $id = (int)($_POST['id'] ?? 0); if ($id === $uid) respond(false, null, 'You cannot delete your own account.'); // Safety: never delete another admin $st = $db->prepare("SELECT is_admin FROM users WHERE id = ?"); $st->execute([$id]); $target = $st->fetch(); if (!$target) respond(false, null, 'User not found.'); if ($target['is_admin']) respond(false, null, 'Cannot delete another admin. Revoke their admin status first.'); $db->prepare("DELETE FROM users WHERE id = ?")->execute([$id]); respond(true); } if ($action === 'admin_reset_password') { if (!isAdmin()) respond(false, null, 'Forbidden'); $id = (int)($_POST['id'] ?? 0); $pw = $_POST['password'] ?? ''; if (strlen($pw) < 6) respond(false, null, 'Password must be at least 6 characters.'); $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?") ->execute([password_hash($pw, PASSWORD_DEFAULT), $id]); respond(true); } if ($action === 'admin_get_settings') { if (!isAdmin()) respond(false, null, 'Forbidden'); $st = $db->query("SELECT setting_key, setting_value FROM settings"); $out = []; foreach ($st->fetchAll() as $row) { $out[$row['setting_key']] = $row['setting_value']; } respond(true, $out); } if ($action === 'admin_save_settings') { if (!isAdmin()) respond(false, null, 'Forbidden'); $allowed = ['site_name','registration_enabled','donation_message','paypal_link','kofi_link','buymeacoffee_link']; $st = $db->prepare("INSERT INTO settings (setting_key, setting_value) VALUES (?,?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)"); foreach ($allowed as $k) { $st->execute([$k, trim($_POST[$k] ?? '')]); } respond(true); } respond(false, null, "Unknown action: $action"); } catch (Exception $e) { respond(false, null, $e->getMessage()); } // ── Helpers ─────────────────────────────────────────────────────────────────── function occursOn(array $tx, DateTime $date): bool { $start = new DateTime($tx['start_date']); if ($date < $start) return false; if ($tx['ends_on'] && $date > new DateTime($tx['ends_on'])) return false; $freq = $tx['frequency']; $iv = (int)$tx['interval_value']; if ($freq === 'monthly') { return $date->format('d') === $start->format('d') && (($date->format('Ym') - $start->format('Ym')) % $iv === 0); } if ($freq === 'weekly') { return ((int)$start->diff($date)->days % (7 * $iv)) === 0; } if ($freq === 'yearly') { return $date->format('m-d') === $start->format('m-d'); } return false; }