// Polyfill browser globals that @actual-app/api v26 expects in Node.js if (typeof globalThis.navigator === 'undefined') { globalThis.navigator = { platform: 'linux', userAgent: 'node' }; } const express = require('express'); const cors = require('cors'); const api = require('@actual-app/api'); const app = express(); app.use(cors()); app.use(express.json()); // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- const ACTUAL_SERVER_URL = process.env.ACTUAL_SERVER_URL || 'http://actualbudget:5006'; const ACTUAL_PASSWORD = process.env.ACTUAL_PASSWORD; const ACTUAL_SYNC_ID = process.env.ACTUAL_SYNC_ID; const PORT = process.env.PORT || 3001; const DATA_DIR = process.env.ACTUAL_DATA_DIR || '/app/data'; // How often (ms) to silently re-sync with the server in the background. const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes let ready = false; // --------------------------------------------------------------------------- // Actual Budget connection lifecycle // --------------------------------------------------------------------------- async function initActual() { console.log(`[budget] Connecting to Actual server at ${ACTUAL_SERVER_URL}`); await api.init({ serverURL: ACTUAL_SERVER_URL, password: ACTUAL_PASSWORD, dataDir: DATA_DIR, }); console.log(`[budget] Downloading budget ${ACTUAL_SYNC_ID}`); await api.downloadBudget(ACTUAL_SYNC_ID); ready = true; console.log('[budget] Budget loaded and ready'); } async function syncBudget() { try { await api.sync(); console.log(`[budget] Background sync completed at ${new Date().toISOString()}`); } catch (err) { console.error('[budget] Background sync failed:', err.message); } } // --------------------------------------------------------------------------- // Middleware - reject requests until the budget is loaded // --------------------------------------------------------------------------- function requireReady(req, res, next) { if (!ready) { return res.status(503).json({ error: 'Budget not loaded yet' }); } next(); } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Convert cents (integer) to a human-readable dollar amount. */ function centsToDollars(cents) { return cents / 100; } /** Get the first and last day of a YYYY-MM month string. */ function monthBounds(month) { const [year, m] = month.split('-').map(Number); const start = `${month}-01`; const lastDay = new Date(year, m, 0).getDate(); const end = `${month}-${String(lastDay).padStart(2, '0')}`; return { start, end }; } /** Current month as YYYY-MM. */ function currentMonth() { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; } /** Build lookup maps for payees, accounts, and categories. */ async function buildLookups() { const [payees, accounts, categories] = await Promise.all([ api.getPayees(), api.getAccounts(), api.getCategories(), ]); const payeeMap = {}; for (const p of payees) payeeMap[p.id] = p.name; const accountMap = {}; for (const a of accounts) accountMap[a.id] = a.name; const categoryMap = {}; for (const c of categories) categoryMap[c.id] = c.name; return { payeeMap, accountMap, categoryMap }; } /** Enrich a transaction with resolved names. */ function enrichTransaction(t, payeeMap, accountMap, categoryMap) { return { ...t, amountDollars: centsToDollars(t.amount), payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || t.payee || '', accountName: t.accountName || accountMap[t.account] || t.account || '', categoryName: categoryMap[t.category] || null, }; } // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- app.get('/health', (_req, res) => { res.json({ status: 'ok', ready }); }); // ---- Accounts ------------------------------------------------------------- app.get('/accounts', requireReady, async (_req, res) => { try { const accounts = await api.getAccounts(); const enriched = await Promise.all( accounts.map(async (acct) => { const balance = await api.getAccountBalance(acct.id); return { ...acct, balance, balanceDollars: centsToDollars(balance), }; }), ); res.json(enriched); } catch (err) { console.error('[budget] GET /accounts error:', err); res.status(500).json({ error: err.message }); } }); // ---- Transactions (filtered) ---------------------------------------------- app.get('/transactions', requireReady, async (req, res) => { try { const { accountId, startDate, endDate, limit, offset } = req.query; if (!accountId) { return res.status(400).json({ error: 'accountId query parameter is required' }); } const start = startDate || '1970-01-01'; const end = endDate || '2099-12-31'; const { payeeMap, accountMap, categoryMap } = await buildLookups(); let txns = await api.getTransactions(accountId, start, end); // Sort newest first txns.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)); const total = txns.length; const pageOffset = parseInt(offset, 10) || 0; const pageLimit = parseInt(limit, 10) || 50; txns = txns.slice(pageOffset, pageOffset + pageLimit); res.json({ total, offset: pageOffset, limit: pageLimit, transactions: txns.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap)), }); } catch (err) { console.error('[budget] GET /transactions error:', err); res.status(500).json({ error: err.message }); } }); // ---- Recent transactions across all accounts ------------------------------ app.get('/transactions/recent', requireReady, async (_req, res) => { try { const limit = parseInt(_req.query.limit, 10) || 20; const accounts = await api.getAccounts(); const { payeeMap, accountMap, categoryMap } = await buildLookups(); const daysBack = new Date(); daysBack.setDate(daysBack.getDate() - 90); const startDate = daysBack.toISOString().slice(0, 10); const endDate = new Date().toISOString().slice(0, 10); let all = []; for (const acct of accounts) { const txns = await api.getTransactions(acct.id, startDate, endDate); all.push(...txns.map((t) => ({ ...t, accountName: acct.name }))); } all.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0)); all = all.slice(0, limit); res.json(all.map((t) => enrichTransaction(t, payeeMap, accountMap, categoryMap))); } catch (err) { console.error('[budget] GET /transactions/recent error:', err); res.status(500).json({ error: err.message }); } }); // ---- Categories ----------------------------------------------------------- app.get('/categories', requireReady, async (_req, res) => { try { const groups = await api.getCategoryGroups(); const categories = await api.getCategories(); const grouped = groups.map((g) => ({ ...g, categories: categories.filter((c) => c.group_id === g.id), })); res.json(grouped); } catch (err) { console.error('[budget] GET /categories error:', err); res.status(500).json({ error: err.message }); } }); // ---- Budget for a month --------------------------------------------------- app.get('/budget/:month', requireReady, async (req, res) => { try { const { month } = req.params; if (!/^\d{4}-\d{2}$/.test(month)) { return res.status(400).json({ error: 'month must be YYYY-MM format' }); } const data = await api.getBudgetMonth(month); res.json(data); } catch (err) { console.error(`[budget] GET /budget/${req.params.month} error:`, err); res.status(500).json({ error: err.message }); } }); // ---- Update a transaction ------------------------------------------------- app.patch('/transactions/:id', requireReady, async (req, res) => { try { const { id } = req.params; const updates = req.body; if (!updates || Object.keys(updates).length === 0) { return res.status(400).json({ error: 'Request body must contain fields to update' }); } await api.updateTransaction(id, updates); await api.sync(); res.json({ success: true, id, updated: updates }); } catch (err) { console.error(`[budget] PATCH /transactions/${req.params.id} error:`, err); res.status(500).json({ error: err.message }); } }); // ---- Make Transfer (link two existing transactions) ----------------------- app.post('/make-transfer', requireReady, async (req, res) => { try { const { transactionId1, transactionId2 } = req.body; if (!transactionId1 || !transactionId2) { return res.status(400).json({ error: 'transactionId1 and transactionId2 are required' }); } // Get payees to find transfer payees for each account const payees = await api.getPayees(); const accounts = await api.getAccounts(); // We need to figure out which account each transaction belongs to // Fetch both transactions by getting all recent + searching const allAccounts = await api.getAccounts(); let tx1 = null; let tx2 = null; for (const acct of allAccounts) { const txns = await api.getTransactions(acct.id, '1970-01-01', '2099-12-31'); if (!tx1) tx1 = txns.find((t) => t.id === transactionId1); if (!tx2) tx2 = txns.find((t) => t.id === transactionId2); if (tx1 && tx2) break; } if (!tx1 || !tx2) { return res.status(404).json({ error: 'Could not find one or both transactions' }); } if (tx1.account === tx2.account) { return res.status(400).json({ error: 'Both transactions are from the same account' }); } // Per Actual docs: set the transfer payee on ONE transaction. // Actual auto-creates the counterpart on the other account. // Then delete the other original transaction (it's now replaced). // Pick the outgoing (negative) transaction to keep, delete the incoming one const keepTx = tx1.amount < 0 ? tx1 : tx2; const deleteTx = tx1.amount < 0 ? tx2 : tx1; const keepId = keepTx === tx1 ? transactionId1 : transactionId2; const deleteId = deleteTx === tx1 ? transactionId1 : transactionId2; // Find the transfer payee for the account we're transferring TO (the deleted tx's account) const targetTransferPayee = payees.find((p) => p.transfer_acct === deleteTx.account); if (!targetTransferPayee) { return res.status(400).json({ error: 'Could not find transfer payee for destination account' }); } // Step 1: Set transfer payee on the kept transaction → Actual creates counterpart await api.updateTransaction(keepId, { payee: targetTransferPayee.id }); // Step 2: Delete the original other transaction (Actual already made a new linked one) await api.deleteTransaction(deleteId); await api.sync(); const acctName1 = accounts.find((a) => a.id === keepTx.account)?.name || keepTx.account; const acctName2 = accounts.find((a) => a.id === deleteTx.account)?.name || deleteTx.account; console.log(`[budget] Linked transfer: ${acctName1} <-> ${acctName2} (kept ${keepId}, deleted ${deleteId})`); res.json({ success: true, linked: { transactionId1: keepId, transactionId2: deleteId, account1: acctName1, account2: acctName2 }, }); } catch (err) { console.error('[budget] POST /make-transfer error:', err); res.status(500).json({ error: err.message }); } }); // ---- Uncategorized count (total across all accounts) ---------------------- app.get('/uncategorized-count', requireReady, async (_req, res) => { try { const accounts = await api.getAccounts(); const startDate = '2000-01-01'; const endDate = new Date().toISOString().slice(0, 10); let total = 0; for (const acct of accounts) { if (acct.closed) continue; const txns = await api.getTransactions(acct.id, startDate, endDate); total += txns.filter((t) => !t.category && !t.transfer_id && t.amount !== 0).length; } res.json({ count: total }); } catch (err) { console.error('[budget] GET /uncategorized-count error:', err); res.status(500).json({ error: err.message }); } }); // ---- Suggested transfers -------------------------------------------------- app.get('/suggested-transfers', requireReady, async (_req, res) => { try { // Sync first to get latest state await api.sync().catch(() => {}); const accounts = await api.getAccounts(); const { payeeMap, accountMap, categoryMap } = await buildLookups(); // Get last 90 days of transactions across all accounts const daysBack = new Date(); daysBack.setDate(daysBack.getDate() - 90); const startDate = daysBack.toISOString().slice(0, 10); const endDate = new Date().toISOString().slice(0, 10); let allTxns = []; for (const acct of accounts) { if (acct.closed) continue; const txns = await api.getTransactions(acct.id, startDate, endDate); allTxns.push( ...txns.map((t) => ({ ...t, accountName: acct.name, payeeName: payeeMap[t.payee] || t.imported_payee || t.notes || '', categoryName: categoryMap[t.category] || null, amountDollars: centsToDollars(t.amount), })), ); } // Only consider transactions that aren't already transfers and aren't categorized const candidates = allTxns.filter( (t) => !t.transfer_id && t.amount !== 0 && !t.starting_balance_flag, ); // Payment-related keywords const paymentKeywords = [ 'payment', 'credit card', 'crd epay', 'online transfer', 'transfer from', 'transfer to', 'autopay', 'bill pay', 'pymt', 'payment received', 'payment thank', ]; function looksLikePayment(t) { const text = ((t.payeeName || '') + ' ' + (t.notes || '')).toLowerCase(); return paymentKeywords.some((kw) => text.includes(kw)); } // Build index by absolute amount const byAmount = {}; for (const t of candidates) { const key = Math.abs(t.amount); if (!byAmount[key]) byAmount[key] = []; byAmount[key].push(t); } const suggestions = []; for (const [amt, group] of Object.entries(byAmount)) { if (group.length < 2) continue; // Find negative (outgoing) and positive (incoming) transactions const negatives = group.filter((t) => t.amount < 0); const positives = group.filter((t) => t.amount > 0); for (const neg of negatives) { for (const pos of positives) { // Must be different accounts if (neg.account === pos.account) continue; // At least one should look like a payment if (!looksLikePayment(neg) && !looksLikePayment(pos)) continue; // Dates should be within 3 days const d1 = new Date(neg.date); const d2 = new Date(pos.date); const daysDiff = Math.abs(d1 - d2) / 86400000; if (daysDiff > 3) continue; // Calculate confidence let confidence = 60; // base: matching amount + different accounts if (looksLikePayment(neg) && looksLikePayment(pos)) confidence += 20; else if (looksLikePayment(neg) || looksLikePayment(pos)) confidence += 10; if (daysDiff === 0) confidence += 15; else if (daysDiff <= 1) confidence += 10; else if (daysDiff <= 2) confidence += 5; // Check if payee name matches an account name (strong signal) const negPayeeLower = (neg.payeeName || '').toLowerCase(); const posPayeeLower = (pos.payeeName || '').toLowerCase(); const negAcctLower = (neg.accountName || '').toLowerCase(); const posAcctLower = (pos.accountName || '').toLowerCase(); if ( negPayeeLower.includes(posAcctLower.slice(0, 10)) || posPayeeLower.includes(negAcctLower.slice(0, 10)) || negAcctLower.includes(negPayeeLower.slice(0, 10)) || posAcctLower.includes(posPayeeLower.slice(0, 10)) ) { confidence += 15; } suggestions.push({ confidence: Math.min(confidence, 100), amount: Math.abs(neg.amount), amountDollars: Math.abs(neg.amountDollars), from: { id: neg.id, account: neg.accountName, accountId: neg.account, payee: neg.payeeName, date: neg.date, notes: neg.notes, }, to: { id: pos.id, account: pos.accountName, accountId: pos.account, payee: pos.payeeName, date: pos.date, notes: pos.notes, }, }); } } } // Sort by confidence desc, then amount desc suggestions.sort((a, b) => b.confidence - a.confidence || b.amount - a.amount); // Deduplicate — each transaction should only appear in one suggestion const used = new Set(); const deduped = []; for (const s of suggestions) { if (used.has(s.from.id) || used.has(s.to.id)) continue; used.add(s.from.id); used.add(s.to.id); deduped.push(s); } res.json(deduped); } catch (err) { console.error('[budget] GET /suggested-transfers error:', err); res.status(500).json({ error: err.message }); } }); // ---- Transfer payees (for "Make Transfer" feature) ------------------------ app.get('/transfer-payees', requireReady, async (_req, res) => { try { const payees = await api.getPayees(); const accounts = await api.getAccounts(); const accountMap = {}; for (const a of accounts) accountMap[a.id] = a.name; // Transfer payees have transfer_acct set — they map to an account const transferPayees = payees .filter((p) => p.transfer_acct) .map((p) => ({ payeeId: p.id, payeeName: p.name, accountId: p.transfer_acct, accountName: accountMap[p.transfer_acct] || p.name, })); res.json(transferPayees); } catch (err) { console.error('[budget] GET /transfer-payees error:', err); res.status(500).json({ error: err.message }); } }); // ---- Dashboard summary ---------------------------------------------------- app.get('/summary', requireReady, async (_req, res) => { try { // Total balance across all accounts const accounts = await api.getAccounts(); let totalBalance = 0; for (const acct of accounts) { totalBalance += await api.getAccountBalance(acct.id); } // Spending this month const month = currentMonth(); const { start, end } = monthBounds(month); let allTxns = []; for (const acct of accounts) { const txns = await api.getTransactions(acct.id, start, end); allTxns.push(...txns); } // Spending = sum of negative amounts (expenses) const spending = allTxns .filter((t) => t.amount < 0) .reduce((sum, t) => sum + t.amount, 0); const income = allTxns .filter((t) => t.amount > 0) .reduce((sum, t) => sum + t.amount, 0); // Top spending categories const categories = await api.getCategories(); const catMap = new Map(categories.map((c) => [c.id, c.name])); const byCat = {}; for (const t of allTxns) { if (t.amount < 0 && t.category) { const name = catMap.get(t.category) || 'Uncategorized'; byCat[name] = (byCat[name] || 0) + t.amount; } } const topCategories = Object.entries(byCat) .map(([name, amount]) => ({ name, amount, amountDollars: centsToDollars(amount) })) .sort((a, b) => a.amount - b.amount) // most negative first .slice(0, 10); res.json({ month, totalBalance, totalBalanceDollars: centsToDollars(totalBalance), spending, spendingDollars: centsToDollars(spending), income, incomeDollars: centsToDollars(income), topCategories, accountCount: accounts.length, transactionCount: allTxns.length, }); } catch (err) { console.error('[budget] GET /summary error:', err); res.status(500).json({ error: err.message }); } }); // ---- Force sync ----------------------------------------------------------- app.post('/sync', requireReady, async (_req, res) => { try { await api.sync(); res.json({ success: true, syncedAt: new Date().toISOString() }); } catch (err) { console.error('[budget] POST /sync error:', err); res.status(500).json({ error: err.message }); } }); // --------------------------------------------------------------------------- // Startup // --------------------------------------------------------------------------- async function start() { if (!ACTUAL_PASSWORD) { console.error('[budget] ACTUAL_PASSWORD environment variable is required'); process.exit(1); } if (!ACTUAL_SYNC_ID) { console.error('[budget] ACTUAL_SYNC_ID environment variable is required'); process.exit(1); } try { await initActual(); } catch (err) { console.error('[budget] Failed to initialise Actual connection:', err); process.exit(1); } // Periodic background sync setInterval(syncBudget, SYNC_INTERVAL_MS); app.listen(PORT, () => { console.log(`[budget] Server listening on port ${PORT}`); }); } // Graceful shutdown async function shutdown() { console.log('[budget] Shutting down...'); try { await api.shutdown(); } catch (_) { // ignore } process.exit(0); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); start();