Read-only Actual Budget API integration with shell helpers for querying accounts, transactions, budgets, categories, and spending.
253 lines
7.1 KiB
JavaScript
253 lines
7.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as api from '@actual-app/api';
|
|
|
|
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);
|
|
}
|
|
|
|
function output(data) {
|
|
console.log(JSON.stringify(data, null, 2));
|
|
}
|
|
|
|
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
|
|
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);
|
|
});
|