Files
platform/services/budget/server.js
Yusuf Suleman 4ecd2336b5
Some checks are pending
Security Checks / dependency-audit (push) Waiting to run
Security Checks / secret-scanning (push) Waiting to run
Security Checks / dockerfile-lint (push) Waiting to run
fix: complete remaining remediation (#5, #8, #9)
#5 Gateway Trust Model:
- Token validation now uses protected endpoints, not health checks
- Unknown services rejected (no fallback to unprotected endpoint)
- Trust model documented in docs/trust-model.md

#8 CI Enforcement:
- Added .gitea/workflows/security.yml with:
  - Dependency audit (npm audit --audit-level=high for budget)
  - Secret scanning (checks for tracked .env/.db, hardcoded secrets)
  - Dockerfile lint (non-root USER, HEALTHCHECK presence)

#9 Performance Hardening:
- Budget /summary: 1-minute in-memory cache (avoids repeated account fan-out)
- Gateway /api/dashboard: 30-second per-user cache (50x faster on repeat)
- Inventory health endpoint added before auth middleware

Closes #5, #8, #9
2026-03-29 10:13:00 -05:00

678 lines
22 KiB
JavaScript

// 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());
// Health check (before auth middleware)
app.get('/health', (req, res) => res.json({ status: 'ok', ready }));
// API key auth middleware — require X-API-Key header on all routes
const SERVICE_API_KEY = process.env.SERVICE_API_KEY || '';
if (SERVICE_API_KEY) {
app.use((req, res, next) => {
const key = req.headers['x-api-key'] || req.query.api_key;
if (key !== SERVICE_API_KEY) {
return res.status(401).json({ error: 'Unauthorized: invalid API key' });
}
next();
});
}
// ---------------------------------------------------------------------------
// 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 (cached) -------------------------------------------
let summaryCache = { data: null, expiresAt: 0 };
const SUMMARY_TTL_MS = 60 * 1000; // 1 minute cache
app.get('/summary', requireReady, async (_req, res) => {
// Return cached summary if fresh
if (summaryCache.data && Date.now() < summaryCache.expiresAt) {
return res.json(summaryCache.data);
}
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);
const result = {
month,
totalBalance,
totalBalanceDollars: centsToDollars(totalBalance),
spending,
spendingDollars: centsToDollars(spending),
income,
incomeDollars: centsToDollars(income),
topCategories,
accountCount: accounts.length,
transactionCount: allTxns.length,
};
summaryCache = { data: result, expiresAt: Date.now() + SUMMARY_TTL_MS };
res.json(result);
} 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();