Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
14
services/budget/Dockerfile
Normal file
14
services/budget/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY server.js ./
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
1443
services/budget/package-lock.json
generated
Normal file
1443
services/budget/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
services/budget/package.json
Normal file
14
services/budget/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "budget-service",
|
||||
"version": "1.0.0",
|
||||
"description": "REST API wrapper for Actual Budget",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "^26.3.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
653
services/budget/server.js
Normal file
653
services/budget/server.js
Normal file
@@ -0,0 +1,653 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user