2026-03-28 23:20:40 -05:00
|
|
|
// 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());
|
|
|
|
|
|
2026-03-29 09:06:41 -05:00
|
|
|
// 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();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 23:20:40 -05:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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();
|