Inter-account transfers have no category and a non-null transfer_id. The spending-by-category command was only filtering out positive amounts (income) but not transfers, causing the negative side of each transfer to appear as "Uncategorized" spending.
259 lines
7.3 KiB
JavaScript
259 lines
7.3 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
// Redirect console.log to stderr so API noise doesn't corrupt JSON output
|
|
const _origLog = console.log;
|
|
console.log = (...args) => console.error(...args);
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as api from '@actual-app/api';
|
|
|
|
// Restore console.log only for our own output function
|
|
function output(data) {
|
|
_origLog(JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
const CONFIG_DIR = path.join(process.env.HOME, '.config', 'actual-budget');
|
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config');
|
|
const PASSWORD_FILE = path.join(CONFIG_DIR, 'password');
|
|
const DATA_DIR = path.join(CONFIG_DIR, 'data');
|
|
|
|
function loadConfig() {
|
|
if (!fs.existsSync(CONFIG_FILE)) {
|
|
console.error(JSON.stringify({ error: 'Config not found. Run setup.sh first.' }));
|
|
process.exit(1);
|
|
}
|
|
if (!fs.existsSync(PASSWORD_FILE)) {
|
|
console.error(JSON.stringify({ error: 'Password file not found. Run setup.sh first.' }));
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = {};
|
|
const lines = fs.readFileSync(CONFIG_FILE, 'utf-8').split('\n');
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx === -1) continue;
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
let val = trimmed.slice(eqIdx + 1).trim();
|
|
// Strip surrounding quotes
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
config[key] = val;
|
|
}
|
|
|
|
config.password = fs.readFileSync(PASSWORD_FILE, 'utf-8').trim();
|
|
return config;
|
|
}
|
|
|
|
async function initialize(config) {
|
|
if (!fs.existsSync(DATA_DIR)) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
await api.init({
|
|
dataDir: DATA_DIR,
|
|
serverURL: config.ACTUAL_SERVER_URL,
|
|
password: config.password,
|
|
});
|
|
|
|
await api.downloadBudget(config.ACTUAL_SYNC_ID);
|
|
}
|
|
|
|
async function main() {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
|
|
if (!command) {
|
|
output({ error: 'No command specified', commands: [
|
|
'accounts', 'balance', 'categories', 'category-groups',
|
|
'budget-months', 'budget-month', 'transactions',
|
|
'spending-by-category', 'payees', 'query', 'budgets'
|
|
]});
|
|
process.exit(1);
|
|
}
|
|
|
|
const config = loadConfig();
|
|
|
|
// 'budgets' command doesn't need a loaded budget
|
|
if (command === 'budgets') {
|
|
if (!fs.existsSync(DATA_DIR)) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
}
|
|
await api.init({
|
|
dataDir: DATA_DIR,
|
|
serverURL: config.ACTUAL_SERVER_URL,
|
|
password: config.password,
|
|
});
|
|
const budgets = await api.getBudgets();
|
|
output(budgets);
|
|
await api.shutdown();
|
|
return;
|
|
}
|
|
|
|
await initialize(config);
|
|
|
|
try {
|
|
switch (command) {
|
|
case 'accounts': {
|
|
const accounts = await api.getAccounts();
|
|
const result = [];
|
|
for (const acct of accounts) {
|
|
const balance = await api.getAccountBalance(acct.id);
|
|
result.push({
|
|
...acct,
|
|
balance: api.utils.integerToAmount(balance),
|
|
});
|
|
}
|
|
output(result);
|
|
break;
|
|
}
|
|
|
|
case 'balance': {
|
|
const accountName = args[0];
|
|
const accounts = await api.getAccounts();
|
|
const filtered = accountName
|
|
? accounts.filter(a => a.name.toLowerCase().includes(accountName.toLowerCase()))
|
|
: accounts;
|
|
const result = [];
|
|
for (const acct of filtered) {
|
|
const balance = await api.getAccountBalance(acct.id);
|
|
result.push({
|
|
name: acct.name,
|
|
balance: api.utils.integerToAmount(balance),
|
|
});
|
|
}
|
|
output(result);
|
|
break;
|
|
}
|
|
|
|
case 'categories': {
|
|
const categories = await api.getCategories();
|
|
output(categories);
|
|
break;
|
|
}
|
|
|
|
case 'category-groups': {
|
|
const groups = await api.getCategoryGroups();
|
|
output(groups);
|
|
break;
|
|
}
|
|
|
|
case 'budget-months': {
|
|
const months = await api.getBudgetMonths();
|
|
output(months);
|
|
break;
|
|
}
|
|
|
|
case 'budget-month': {
|
|
const month = args[0] || new Date().toISOString().slice(0, 7);
|
|
const budget = await api.getBudgetMonth(month);
|
|
output(budget);
|
|
break;
|
|
}
|
|
|
|
case 'transactions': {
|
|
const accountId = args[0] || null;
|
|
const startDate = args[1] || null;
|
|
const endDate = args[2] || null;
|
|
|
|
if (accountId) {
|
|
// Check if it's a name rather than an ID
|
|
let resolvedId = accountId;
|
|
const accounts = await api.getAccounts();
|
|
const match = accounts.find(a =>
|
|
a.name.toLowerCase() === accountId.toLowerCase() ||
|
|
a.id === accountId
|
|
);
|
|
if (match) resolvedId = match.id;
|
|
|
|
const txns = await api.getTransactions(resolvedId, startDate, endDate);
|
|
output(txns.map(t => ({
|
|
...t,
|
|
amount: api.utils.integerToAmount(t.amount),
|
|
})));
|
|
} else {
|
|
// Get transactions from all accounts
|
|
const accounts = await api.getAccounts();
|
|
const allTxns = [];
|
|
for (const acct of accounts) {
|
|
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
|
allTxns.push(...txns.map(t => ({
|
|
...t,
|
|
account_name: acct.name,
|
|
amount: api.utils.integerToAmount(t.amount),
|
|
})));
|
|
}
|
|
allTxns.sort((a, b) => b.date.localeCompare(a.date));
|
|
output(allTxns);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'spending-by-category': {
|
|
const startDate = args[0] || null;
|
|
const endDate = args[1] || null;
|
|
|
|
const accounts = await api.getAccounts();
|
|
const categories = await api.getCategories();
|
|
const categoryMap = {};
|
|
for (const cat of categories) {
|
|
categoryMap[cat.id] = cat.name;
|
|
}
|
|
|
|
const spending = {};
|
|
for (const acct of accounts) {
|
|
if (acct.offbudget || acct.closed) continue;
|
|
const txns = await api.getTransactions(acct.id, startDate, endDate);
|
|
for (const t of txns) {
|
|
if (t.amount >= 0) continue; // skip income
|
|
if (t.transfer_id) continue; // skip inter-account transfers
|
|
const catName = categoryMap[t.category] || 'Uncategorized';
|
|
spending[catName] = (spending[catName] || 0) + t.amount;
|
|
}
|
|
}
|
|
|
|
const result = Object.entries(spending)
|
|
.map(([category, amount]) => ({
|
|
category,
|
|
amount: api.utils.integerToAmount(amount),
|
|
}))
|
|
.sort((a, b) => a.amount - b.amount);
|
|
|
|
output(result);
|
|
break;
|
|
}
|
|
|
|
case 'payees': {
|
|
const payees = await api.getPayees();
|
|
output(payees);
|
|
break;
|
|
}
|
|
|
|
case 'query': {
|
|
const queryJson = args[0];
|
|
if (!queryJson) {
|
|
output({ error: 'Query JSON required' });
|
|
process.exit(1);
|
|
}
|
|
const queryObj = JSON.parse(queryJson);
|
|
const result = await api.runQuery(queryObj);
|
|
output(result);
|
|
break;
|
|
}
|
|
|
|
default:
|
|
output({ error: `Unknown command: ${command}` });
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await api.shutdown();
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(JSON.stringify({ error: err.message }));
|
|
process.exit(1);
|
|
});
|