FinancePlanner/api.php
2026-05-29 12:00:49 +01:00

453 lines
19 KiB
PHP

<?php
// ─────────────────────────────────────────────
// api.php · JSON API — all AJAX endpoints
// ─────────────────────────────────────────────
require_once 'db.php';
require_once 'auth.php';
header('Content-Type: application/json');
function respond(bool $ok, $data = null, string $error = ''): void {
echo json_encode($ok
? ['ok' => 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;
}