actual-mcp-server 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +663 -0
- package/bin/actual-mcp-server.js +3 -0
- package/dist/generated/actual-client/types.js +5 -0
- package/dist/package.json +88 -0
- package/dist/src/actualConnection.js +157 -0
- package/dist/src/actualToolsManager.js +211 -0
- package/dist/src/auth/budget-acl.js +143 -0
- package/dist/src/auth/setup.js +58 -0
- package/dist/src/config.js +41 -0
- package/dist/src/index.js +313 -0
- package/dist/src/lib/ActualConnectionPool.js +343 -0
- package/dist/src/lib/ActualMCPConnection.js +125 -0
- package/dist/src/lib/actual-adapter.js +1228 -0
- package/dist/src/lib/actual-schema.js +222 -0
- package/dist/src/lib/budget-registry.js +64 -0
- package/dist/src/lib/constants.js +121 -0
- package/dist/src/lib/errors.js +19 -0
- package/dist/src/lib/loggerFactory.js +72 -0
- package/dist/src/lib/node-polyfills.js +20 -0
- package/dist/src/lib/query-validator.js +221 -0
- package/dist/src/lib/retry.js +26 -0
- package/dist/src/lib/schemas/common.js +203 -0
- package/dist/src/lib/toolFactory.js +109 -0
- package/dist/src/logger.js +127 -0
- package/dist/src/observability.js +58 -0
- package/dist/src/prompts/showLargeTransactions.js +6 -0
- package/dist/src/resources/accountsSummary.js +13 -0
- package/dist/src/server/httpServer.js +540 -0
- package/dist/src/server/httpServer_testing.js +401 -0
- package/dist/src/server/stdioServer.js +52 -0
- package/dist/src/server/streamable-http.js +148 -0
- package/dist/src/tests/actualToolsTests.js +70 -0
- package/dist/src/tests/observability.smoke.test.js +18 -0
- package/dist/src/tests/testMcpClient.js +170 -0
- package/dist/src/tests_adapter_runner.js +86 -0
- package/dist/src/tools/accounts_close.js +16 -0
- package/dist/src/tools/accounts_create.js +27 -0
- package/dist/src/tools/accounts_delete.js +16 -0
- package/dist/src/tools/accounts_get_balance.js +40 -0
- package/dist/src/tools/accounts_list.js +16 -0
- package/dist/src/tools/accounts_reopen.js +16 -0
- package/dist/src/tools/accounts_update.js +52 -0
- package/dist/src/tools/bank_sync.js +22 -0
- package/dist/src/tools/budget_updates_batch.js +77 -0
- package/dist/src/tools/budgets_getMonth.js +14 -0
- package/dist/src/tools/budgets_getMonths.js +14 -0
- package/dist/src/tools/budgets_get_all.js +13 -0
- package/dist/src/tools/budgets_holdForNextMonth.js +19 -0
- package/dist/src/tools/budgets_list_available.js +20 -0
- package/dist/src/tools/budgets_resetHold.js +16 -0
- package/dist/src/tools/budgets_setAmount.js +26 -0
- package/dist/src/tools/budgets_setCarryover.js +18 -0
- package/dist/src/tools/budgets_switch.js +27 -0
- package/dist/src/tools/budgets_transfer.js +64 -0
- package/dist/src/tools/categories_create.js +65 -0
- package/dist/src/tools/categories_delete.js +16 -0
- package/dist/src/tools/categories_get.js +14 -0
- package/dist/src/tools/categories_update.js +22 -0
- package/dist/src/tools/category_groups_create.js +18 -0
- package/dist/src/tools/category_groups_delete.js +26 -0
- package/dist/src/tools/category_groups_get.js +13 -0
- package/dist/src/tools/category_groups_update.js +21 -0
- package/dist/src/tools/get_id_by_name.js +36 -0
- package/dist/src/tools/index.js +63 -0
- package/dist/src/tools/payee_rules_get.js +27 -0
- package/dist/src/tools/payees_create.js +25 -0
- package/dist/src/tools/payees_delete.js +16 -0
- package/dist/src/tools/payees_get.js +14 -0
- package/dist/src/tools/payees_merge.js +17 -0
- package/dist/src/tools/payees_update.js +59 -0
- package/dist/src/tools/query_run.js +78 -0
- package/dist/src/tools/rules_create.js +129 -0
- package/dist/src/tools/rules_create_or_update.js +191 -0
- package/dist/src/tools/rules_delete.js +26 -0
- package/dist/src/tools/rules_get.js +13 -0
- package/dist/src/tools/rules_update.js +120 -0
- package/dist/src/tools/schedules_create.js +54 -0
- package/dist/src/tools/schedules_delete.js +41 -0
- package/dist/src/tools/schedules_get.js +13 -0
- package/dist/src/tools/schedules_update.js +40 -0
- package/dist/src/tools/server_get_version.js +22 -0
- package/dist/src/tools/server_info.js +86 -0
- package/dist/src/tools/session_close.js +100 -0
- package/dist/src/tools/session_list.js +24 -0
- package/dist/src/tools/transactions_create.js +50 -0
- package/dist/src/tools/transactions_delete.js +20 -0
- package/dist/src/tools/transactions_filter.js +73 -0
- package/dist/src/tools/transactions_get.js +23 -0
- package/dist/src/tools/transactions_import.js +21 -0
- package/dist/src/tools/transactions_search_by_amount.js +126 -0
- package/dist/src/tools/transactions_search_by_category.js +137 -0
- package/dist/src/tools/transactions_search_by_month.js +142 -0
- package/dist/src/tools/transactions_search_by_payee.js +142 -0
- package/dist/src/tools/transactions_summary_by_category.js +80 -0
- package/dist/src/tools/transactions_summary_by_payee.js +72 -0
- package/dist/src/tools/transactions_uncategorized.js +66 -0
- package/dist/src/tools/transactions_update.js +34 -0
- package/dist/src/tools/transactions_update_batch.js +60 -0
- package/dist/src/utils.js +63 -0
- package/package.json +88 -0
|
@@ -0,0 +1,1228 @@
|
|
|
1
|
+
// Must be the very first import — sets globalThis.navigator before @actual-app/api is evaluated.
|
|
2
|
+
// Required by @actual-app/api v26.3.0+ which uses navigator.platform at module load time.
|
|
3
|
+
import './node-polyfills.js';
|
|
4
|
+
import api from '@actual-app/api';
|
|
5
|
+
// @actual-app/api is a CJS package (no "type" field). In NodeNext/ESM context TypeScript
|
|
6
|
+
// cannot expose its named exports via static import syntax. At runtime the default import
|
|
7
|
+
// IS module.exports, so all methods are accessible as properties.
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
const { addTransactions: rawAddTransactions, getAccounts: rawGetAccounts, importTransactions: rawImportTransactions, getTransactions: rawGetTransactions, getCategories: rawGetCategories, createCategory: rawCreateCategory, getPayees: rawGetPayees, createPayee: rawCreatePayee, getBudgetMonths: rawGetBudgetMonths, getBudgetMonth: rawGetBudgetMonth, setBudgetAmount: rawSetBudgetAmount, createAccount: rawCreateAccount, updateAccount: rawUpdateAccount, getAccountBalance: rawGetAccountBalance, updateTransaction: rawUpdateTransaction, deleteTransaction: rawDeleteTransaction, updateCategory: rawUpdateCategory, deleteCategory: rawDeleteCategory, updatePayee: rawUpdatePayee, deletePayee: rawDeletePayee, deleteAccount: rawDeleteAccount, getRules: rawGetRules, createRule: rawCreateRule, updateRule: rawUpdateRule, deleteRule: rawDeleteRule, setBudgetCarryover: rawSetBudgetCarryover, closeAccount: rawCloseAccount, reopenAccount: rawReopenAccount, getCategoryGroups: rawGetCategoryGroups, createCategoryGroup: rawCreateCategoryGroup, updateCategoryGroup: rawUpdateCategoryGroup, deleteCategoryGroup: rawDeleteCategoryGroup, mergePayees: rawMergePayees, getPayeeRules: rawGetPayeeRules, batchBudgetUpdates: rawBatchBudgetUpdates, holdBudgetForNextMonth: rawHoldBudgetForNextMonth, resetBudgetHold: rawResetBudgetHold, runQuery: rawRunQuery, runBankSync: rawRunBankSync, getBudgets: rawGetBudgets, getIDByName: rawGetIDByName, getServerVersion: rawGetServerVersion, getSchedules: rawGetSchedules, createSchedule: rawCreateSchedule, updateSchedule: rawUpdateSchedule, deleteSchedule: rawDeleteSchedule,
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
} = api;
|
|
12
|
+
import { EventEmitter } from 'events';
|
|
13
|
+
import observability from '../observability.js';
|
|
14
|
+
import retry from './retry.js';
|
|
15
|
+
import logger from '../logger.js';
|
|
16
|
+
import config from '../config.js';
|
|
17
|
+
import { parseBudgetRegistry } from './budget-registry.js';
|
|
18
|
+
/**
|
|
19
|
+
* Budget registry — all budgets configured via ACTUAL_* and BUDGET_n_* env vars.
|
|
20
|
+
* Built once at startup; used by every withActualApi call.
|
|
21
|
+
*/
|
|
22
|
+
const budgetRegistry = parseBudgetRegistry(process.env, {
|
|
23
|
+
serverUrl: config.ACTUAL_SERVER_URL,
|
|
24
|
+
password: config.ACTUAL_PASSWORD,
|
|
25
|
+
syncId: config.ACTUAL_BUDGET_SYNC_ID,
|
|
26
|
+
encryptionPassword: config.ACTUAL_BUDGET_PASSWORD,
|
|
27
|
+
});
|
|
28
|
+
logger.info(`[ADAPTER] Budget registry: ${budgetRegistry.size} budget(s) — ` +
|
|
29
|
+
[...budgetRegistry.values()].map(b => `"${b.name}" (${b.serverUrl})`).join(', '));
|
|
30
|
+
/**
|
|
31
|
+
* Key (lowercased budget name) of the currently active budget.
|
|
32
|
+
* Null = use the first entry in the registry (the default budget).
|
|
33
|
+
*/
|
|
34
|
+
let activeBudgetKey = null;
|
|
35
|
+
function getActiveBudgetConfig() {
|
|
36
|
+
if (activeBudgetKey) {
|
|
37
|
+
const found = budgetRegistry.get(activeBudgetKey);
|
|
38
|
+
if (found)
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
return [...budgetRegistry.values()][0];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Global API session mutex.
|
|
45
|
+
* @actual-app/api is a singleton with a single SQLite connection — concurrent
|
|
46
|
+
* init/shutdown pairs corrupt the session. All callers (reads via withActualApi,
|
|
47
|
+
* writes via processWriteQueue) must acquire this lock before touching the API.
|
|
48
|
+
*/
|
|
49
|
+
let _apiSessionLock = Promise.resolve();
|
|
50
|
+
function withApiLock(fn) {
|
|
51
|
+
let release;
|
|
52
|
+
const prevLock = _apiSessionLock;
|
|
53
|
+
_apiSessionLock = new Promise(resolve => { release = resolve; });
|
|
54
|
+
return prevLock.then(() => fn()).finally(() => release());
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Helper to init and shutdown Actual API around each operation
|
|
58
|
+
* This is CRITICAL for data persistence - shutdown() must be called after every operation
|
|
59
|
+
* Based on the pattern from https://github.com/s-stefanov/actual-mcp
|
|
60
|
+
*/
|
|
61
|
+
async function withActualApi(operation) {
|
|
62
|
+
return withApiLock(async () => {
|
|
63
|
+
try {
|
|
64
|
+
await initActualApiForOperation();
|
|
65
|
+
return await operation();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await shutdownActualApi();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Initialize Actual API - based on s-stefanov/actual-mcp pattern
|
|
74
|
+
* This calls api.init() and api.downloadBudget() for each operation
|
|
75
|
+
*/
|
|
76
|
+
async function initActualApiForOperation() {
|
|
77
|
+
try {
|
|
78
|
+
const budget = getActiveBudgetConfig();
|
|
79
|
+
const DATA_DIR = config.MCP_BRIDGE_DATA_DIR;
|
|
80
|
+
logger.debug(`[ADAPTER] Initializing Actual API for operation (budget: "${budget.name}", server: ${budget.serverUrl})`);
|
|
81
|
+
await api.init({
|
|
82
|
+
dataDir: DATA_DIR,
|
|
83
|
+
serverURL: budget.serverUrl,
|
|
84
|
+
password: budget.password || '',
|
|
85
|
+
});
|
|
86
|
+
logger.debug('[ADAPTER] Downloading budget');
|
|
87
|
+
if (budget.encryptionPassword) {
|
|
88
|
+
const apiWithOptions = api;
|
|
89
|
+
await apiWithOptions.downloadBudget(budget.syncId, { password: budget.encryptionPassword });
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
await api.downloadBudget(budget.syncId);
|
|
93
|
+
}
|
|
94
|
+
logger.debug('[ADAPTER] Actual API initialized for operation');
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
logger.error('[ADAPTER] Error initializing Actual API:', err);
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function shutdownActualApi() {
|
|
102
|
+
try {
|
|
103
|
+
const maybeApi = api;
|
|
104
|
+
if (typeof maybeApi.shutdown === 'function') {
|
|
105
|
+
await maybeApi.shutdown();
|
|
106
|
+
logger.debug('[ADAPTER] Actual API shutdown complete');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
logger.error('[ADAPTER] Error during Actual API shutdown:', err);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
import { BANK_SYNC_SETTLE_MS, DEFAULT_CONCURRENCY_LIMIT, WRITE_SESSION_DELAY_MS } from './constants.js';
|
|
114
|
+
/**
|
|
115
|
+
* Very small concurrency limiter for adapter calls. This prevents bursts from
|
|
116
|
+
* overloading the actual server. It's intentionally tiny and in-memory; replace
|
|
117
|
+
* with Bottleneck or p-queue for production.
|
|
118
|
+
*/
|
|
119
|
+
let MAX_CONCURRENCY = parseInt(process.env.ACTUAL_API_CONCURRENCY || String(DEFAULT_CONCURRENCY_LIMIT), 10);
|
|
120
|
+
let running = 0;
|
|
121
|
+
const queue = [];
|
|
122
|
+
let writeQueue = [];
|
|
123
|
+
let isProcessingWrites = false;
|
|
124
|
+
let writeSessionTimeout = null;
|
|
125
|
+
async function processWriteQueue() {
|
|
126
|
+
// Atomically check and set processing flag to prevent race conditions
|
|
127
|
+
if (isProcessingWrites || writeQueue.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
isProcessingWrites = true;
|
|
130
|
+
// Clear the timeout since we're processing now
|
|
131
|
+
if (writeSessionTimeout) {
|
|
132
|
+
clearTimeout(writeSessionTimeout);
|
|
133
|
+
writeSessionTimeout = null;
|
|
134
|
+
}
|
|
135
|
+
const batch = writeQueue.splice(0, writeQueue.length); // Take all current items
|
|
136
|
+
logger.debug(`[WRITE QUEUE] Processing batch of ${batch.length} operations`);
|
|
137
|
+
try {
|
|
138
|
+
await withApiLock(async () => {
|
|
139
|
+
try {
|
|
140
|
+
// Initialize API once for all queued writes
|
|
141
|
+
await initActualApiForOperation();
|
|
142
|
+
// Process all queued writes in the same session
|
|
143
|
+
// Each operation handles its own success/failure
|
|
144
|
+
await Promise.allSettled(batch.map(async ({ operation, resolve, reject }) => {
|
|
145
|
+
try {
|
|
146
|
+
const result = await operation();
|
|
147
|
+
resolve(result);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
logger.error('[WRITE QUEUE] Operation failed:', error);
|
|
151
|
+
reject(error);
|
|
152
|
+
}
|
|
153
|
+
}));
|
|
154
|
+
// Explicitly sync changes to server before shutdown
|
|
155
|
+
// This ensures all write operations are persisted
|
|
156
|
+
logger.debug(`[WRITE QUEUE] Syncing ${batch.length} operations to server`);
|
|
157
|
+
try {
|
|
158
|
+
await api.sync();
|
|
159
|
+
logger.debug(`[WRITE QUEUE] Sync completed`);
|
|
160
|
+
}
|
|
161
|
+
catch (syncError) {
|
|
162
|
+
logger.error('[WRITE QUEUE] Sync failed:', syncError);
|
|
163
|
+
// Don't throw - we still want to shutdown cleanly
|
|
164
|
+
// Individual operation errors were already reported to callers
|
|
165
|
+
}
|
|
166
|
+
// Shutdown after all writes complete and sync
|
|
167
|
+
await shutdownActualApi();
|
|
168
|
+
logger.debug(`[WRITE QUEUE] Batch completed successfully`);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger.error('[WRITE QUEUE] Fatal error in write queue:', error);
|
|
172
|
+
// Reject any operations that weren't processed
|
|
173
|
+
batch.forEach(({ reject }) => {
|
|
174
|
+
try {
|
|
175
|
+
reject(error);
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
logger.error('[WRITE QUEUE] Error rejecting operation:', e);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
await shutdownActualApi();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
isProcessingWrites = false;
|
|
187
|
+
// Process any new operations that were queued while we were processing
|
|
188
|
+
if (writeQueue.length > 0 && writeSessionTimeout === null) {
|
|
189
|
+
writeSessionTimeout = setTimeout(() => {
|
|
190
|
+
processWriteQueue();
|
|
191
|
+
}, WRITE_SESSION_DELAY_MS);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function queueWriteOperation(operation) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
writeQueue.push({ operation, resolve, reject });
|
|
198
|
+
// Clear existing timeout
|
|
199
|
+
if (writeSessionTimeout) {
|
|
200
|
+
clearTimeout(writeSessionTimeout);
|
|
201
|
+
}
|
|
202
|
+
// Set new timeout to process queue
|
|
203
|
+
writeSessionTimeout = setTimeout(() => {
|
|
204
|
+
processWriteQueue();
|
|
205
|
+
}, WRITE_SESSION_DELAY_MS);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function processQueue() {
|
|
209
|
+
if (running >= MAX_CONCURRENCY)
|
|
210
|
+
return;
|
|
211
|
+
const next = queue.shift();
|
|
212
|
+
if (!next)
|
|
213
|
+
return;
|
|
214
|
+
running++;
|
|
215
|
+
try {
|
|
216
|
+
next();
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
// next() will manage its own promise resolution
|
|
220
|
+
running--;
|
|
221
|
+
processQueue();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function withConcurrency(fn) {
|
|
225
|
+
if (running < MAX_CONCURRENCY) {
|
|
226
|
+
running++;
|
|
227
|
+
return fn().finally(() => {
|
|
228
|
+
running--;
|
|
229
|
+
processQueue();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
queue.push(async () => {
|
|
234
|
+
try {
|
|
235
|
+
const r = await fn();
|
|
236
|
+
resolve(r);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
reject(err);
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
running--;
|
|
243
|
+
processQueue();
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Expose some helpers for testing concurrency
|
|
249
|
+
export function getConcurrencyState() {
|
|
250
|
+
return { running, queueLength: queue.length, maxConcurrency: MAX_CONCURRENCY };
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Sync local changes to the Actual Budget server.
|
|
254
|
+
*
|
|
255
|
+
* This function should be called after write operations (create, update, delete)
|
|
256
|
+
* to ensure changes are properly synced to the remote server. Without syncing,
|
|
257
|
+
* changes may only exist locally and could be lost.
|
|
258
|
+
*
|
|
259
|
+
* Note: Adds a small delay to ensure local changes are committed before syncing.
|
|
260
|
+
*/
|
|
261
|
+
async function syncToServer() {
|
|
262
|
+
try {
|
|
263
|
+
// Small delay to ensure local changes are committed to the database
|
|
264
|
+
// before attempting to sync to the server
|
|
265
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
266
|
+
// TypeScript doesn't recognize sync in the type definitions, but it exists at runtime
|
|
267
|
+
console.log('[SYNC] Calling api.sync()...');
|
|
268
|
+
await api.sync();
|
|
269
|
+
console.log('[SYNC] api.sync() completed successfully');
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// Log but don't throw - sync failures shouldn't break the operation
|
|
273
|
+
console.error('[SYNC] Sync to server failed:', err);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export function setMaxConcurrency(n) {
|
|
277
|
+
MAX_CONCURRENCY = n;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Wrap a raw function with the standard adapter retry + concurrency behavior.
|
|
281
|
+
* Useful for tests that want to exercise retry behavior without calling the real raw methods.
|
|
282
|
+
*/
|
|
283
|
+
export function callWithRetry(fn, opts) {
|
|
284
|
+
// retry already types the options; forward them directly and let TypeScript
|
|
285
|
+
// validate shapes rather than using `as any`.
|
|
286
|
+
return withConcurrency(() => retry(fn, opts));
|
|
287
|
+
}
|
|
288
|
+
export const notifications = new EventEmitter();
|
|
289
|
+
// --- Normalization helpers -------------------------------------------------
|
|
290
|
+
export function normalizeToTransactionArray(raw) {
|
|
291
|
+
if (!raw)
|
|
292
|
+
return [];
|
|
293
|
+
// If already an array of transactions
|
|
294
|
+
if (Array.isArray(raw) && raw.every(r => typeof r === 'object'))
|
|
295
|
+
return raw;
|
|
296
|
+
// If a single transaction object, wrap it
|
|
297
|
+
if (typeof raw === 'object' && raw !== null && 'id' in raw)
|
|
298
|
+
return [raw];
|
|
299
|
+
// If array of ids returned, convert to minimal Transaction objects
|
|
300
|
+
if (Array.isArray(raw) && raw.every(r => typeof r === 'string')) {
|
|
301
|
+
return raw.map(id => ({ id }));
|
|
302
|
+
}
|
|
303
|
+
// Fallback: try to coerce
|
|
304
|
+
return Array.isArray(raw) ? raw : [];
|
|
305
|
+
}
|
|
306
|
+
export function normalizeToId(raw) {
|
|
307
|
+
if (typeof raw === 'string')
|
|
308
|
+
return raw;
|
|
309
|
+
if (raw && typeof raw === 'object' && 'id' in raw) {
|
|
310
|
+
const idVal = raw['id'];
|
|
311
|
+
if (typeof idVal === 'string')
|
|
312
|
+
return idVal;
|
|
313
|
+
}
|
|
314
|
+
if (Array.isArray(raw) && raw.length > 0 && typeof raw[0] === 'string')
|
|
315
|
+
return raw[0];
|
|
316
|
+
return String(raw ?? '');
|
|
317
|
+
}
|
|
318
|
+
export function normalizeImportResult(raw) {
|
|
319
|
+
if (!raw || typeof raw !== 'object')
|
|
320
|
+
return { added: [], updated: [], errors: [] };
|
|
321
|
+
const r = raw;
|
|
322
|
+
return {
|
|
323
|
+
added: Array.isArray(r.added) ? r.added : [],
|
|
324
|
+
updated: Array.isArray(r.updated) ? r.updated : [],
|
|
325
|
+
errors: Array.isArray(r.errors) ? r.errors : [],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
export async function getAccounts() {
|
|
330
|
+
return withActualApi(async () => {
|
|
331
|
+
observability.incrementToolCall('actual.accounts.list').catch(() => { });
|
|
332
|
+
return await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
// addTransactions returns various formats: "ok", array of IDs, or Transaction objects
|
|
336
|
+
export async function addTransactions(txs) {
|
|
337
|
+
observability.incrementToolCall('actual.transactions.create').catch(() => { });
|
|
338
|
+
return queueWriteOperation(async () => {
|
|
339
|
+
// The Actual API expects addTransactions(accountId, transactions, options)
|
|
340
|
+
// Extract accountId from the first transaction and remove it from transaction objects
|
|
341
|
+
const txArray = Array.isArray(txs) ? txs : [txs];
|
|
342
|
+
if (txArray.length === 0) {
|
|
343
|
+
throw new Error('No transactions provided');
|
|
344
|
+
}
|
|
345
|
+
const accountId = txArray[0].account || txArray[0].accountId;
|
|
346
|
+
if (!accountId) {
|
|
347
|
+
throw new Error('Transaction must include account or accountId');
|
|
348
|
+
}
|
|
349
|
+
// Remove account/accountId from transaction objects as they're passed separately
|
|
350
|
+
const cleanedTxs = txArray.map(tx => {
|
|
351
|
+
const { account, accountId: _, ...rest } = tx;
|
|
352
|
+
return rest;
|
|
353
|
+
});
|
|
354
|
+
// API docs say it returns id[], but reality is it can return "ok", array of IDs, or Transaction objects
|
|
355
|
+
const result = await withConcurrency(() => retry(() => rawAddTransactions(accountId, cleanedTxs, {}), { retries: 2, backoffMs: 200 }));
|
|
356
|
+
// Handle various return formats
|
|
357
|
+
if (result === 'ok') {
|
|
358
|
+
// Transaction created successfully but no IDs returned
|
|
359
|
+
// We'll need to query the account to get the transaction IDs
|
|
360
|
+
return ['ok']; // Return success indicator
|
|
361
|
+
}
|
|
362
|
+
else if (Array.isArray(result)) {
|
|
363
|
+
// Could be array of IDs (strings) or array of Transaction objects
|
|
364
|
+
if (result.length === 0)
|
|
365
|
+
return [];
|
|
366
|
+
if (typeof result[0] === 'string')
|
|
367
|
+
return result;
|
|
368
|
+
if (typeof result[0] === 'object' && result[0] !== null && 'id' in result[0]) {
|
|
369
|
+
return result.map((t) => t.id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
else if (typeof result === 'object' && result !== null && 'id' in result) {
|
|
373
|
+
// Single Transaction object
|
|
374
|
+
return [result.id];
|
|
375
|
+
}
|
|
376
|
+
return [];
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
export async function importTransactions(accountId, txs) {
|
|
380
|
+
observability.incrementToolCall('actual.transactions.import').catch(() => { });
|
|
381
|
+
return queueWriteOperation(async () => {
|
|
382
|
+
const raw = await withConcurrency(() => retry(() => rawImportTransactions(accountId, txs), { retries: 2, backoffMs: 200 }));
|
|
383
|
+
return raw || { added: [], updated: [], errors: [] };
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
export async function getTransactions(accountId, startDate, endDate) {
|
|
387
|
+
return withActualApi(async () => {
|
|
388
|
+
observability.incrementToolCall('actual.transactions.get').catch(() => { });
|
|
389
|
+
return await withConcurrency(() => retry(() => rawGetTransactions(accountId, startDate, endDate), { retries: 2, backoffMs: 200 }));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
export async function getCategories() {
|
|
393
|
+
return withActualApi(async () => {
|
|
394
|
+
observability.incrementToolCall('actual.categories.get').catch(() => { });
|
|
395
|
+
return await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
export async function createCategory(category) {
|
|
399
|
+
observability.incrementToolCall('actual.categories.create').catch(() => { });
|
|
400
|
+
return queueWriteOperation(async () => {
|
|
401
|
+
try {
|
|
402
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategory(category), { retries: 0, backoffMs: 200 }));
|
|
403
|
+
return normalizeToId(raw);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
logger.error('[CREATE CATEGORY] Error creating category:', error);
|
|
407
|
+
// Re-throw the error with proper context
|
|
408
|
+
if (error instanceof Error) {
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
// Handle Actual APIError plain objects: { type: "APIError", message: "..." }
|
|
412
|
+
const msg = error?.message ? String(error.message) : JSON.stringify(error);
|
|
413
|
+
throw new Error(msg);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
export async function getPayees() {
|
|
418
|
+
return withActualApi(async () => {
|
|
419
|
+
observability.incrementToolCall('actual.payees.get').catch(() => { });
|
|
420
|
+
return await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
export async function createPayee(payee) {
|
|
424
|
+
observability.incrementToolCall('actual.payees.create').catch(() => { });
|
|
425
|
+
return queueWriteOperation(async () => {
|
|
426
|
+
const raw = await withConcurrency(() => retry(() => rawCreatePayee(payee), { retries: 2, backoffMs: 200 }));
|
|
427
|
+
return normalizeToId(raw);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
export async function getBudgetMonths() {
|
|
431
|
+
return withActualApi(async () => {
|
|
432
|
+
observability.incrementToolCall('actual.budgets.getMonths').catch(() => { });
|
|
433
|
+
return await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
export async function getBudgetMonth(month) {
|
|
437
|
+
return withActualApi(async () => {
|
|
438
|
+
observability.incrementToolCall('actual.budgets.getMonth').catch(() => { });
|
|
439
|
+
return await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200 }));
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
export async function setBudgetAmount(month, categoryId, amount) {
|
|
443
|
+
observability.incrementToolCall('actual.budgets.setAmount').catch(() => { });
|
|
444
|
+
return queueWriteOperation(async () => {
|
|
445
|
+
// Pre-flight: verify category exists — nil/unknown UUIDs silently no-op in Actual Budget
|
|
446
|
+
const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
|
|
447
|
+
const exists = categories.some((c) => c.id === categoryId);
|
|
448
|
+
if (!exists) {
|
|
449
|
+
throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
|
|
450
|
+
}
|
|
451
|
+
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200 }));
|
|
452
|
+
return result;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
export async function createAccount(account, initialBalance) {
|
|
456
|
+
observability.incrementToolCall('actual.accounts.create').catch(() => { });
|
|
457
|
+
return queueWriteOperation(async () => {
|
|
458
|
+
const raw = await withConcurrency(() => retry(() => rawCreateAccount(account, initialBalance), { retries: 2, backoffMs: 200 }));
|
|
459
|
+
const id = normalizeToId(raw);
|
|
460
|
+
// NO NEED for syncToServer() - shutdown() will handle persistence
|
|
461
|
+
return id;
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
export async function updateAccount(id, fields) {
|
|
465
|
+
observability.incrementToolCall('actual.accounts.update').catch(() => { });
|
|
466
|
+
return queueWriteOperation(async () => {
|
|
467
|
+
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200 }));
|
|
468
|
+
return null;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
export async function getAccountBalance(id, cutoff) {
|
|
472
|
+
return withActualApi(async () => {
|
|
473
|
+
observability.incrementToolCall('actual.accounts.get.balance').catch(() => { });
|
|
474
|
+
return await withConcurrency(() => retry(() => rawGetAccountBalance(id, cutoff), { retries: 2, backoffMs: 200 }));
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Fetch all accounts with their current balances in a single API session.
|
|
479
|
+
* Using a single withActualApi session avoids N separate init/shutdown cycles
|
|
480
|
+
* that would occur if you called getAccountBalance() once per account.
|
|
481
|
+
*/
|
|
482
|
+
export async function getAccountsWithBalances() {
|
|
483
|
+
return withActualApi(async () => {
|
|
484
|
+
observability.incrementToolCall('actual.accounts.list').catch(() => { });
|
|
485
|
+
const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
486
|
+
const result = [];
|
|
487
|
+
for (const account of accounts) {
|
|
488
|
+
try {
|
|
489
|
+
const balance = await rawGetAccountBalance(account.id);
|
|
490
|
+
result.push({ ...account, balance_current: balance });
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
result.push({ ...account, balance_current: null });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return result;
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
export async function deleteAccount(id) {
|
|
500
|
+
observability.incrementToolCall('actual.accounts.delete').catch(() => { });
|
|
501
|
+
return queueWriteOperation(async () => {
|
|
502
|
+
await withConcurrency(() => retry(() => rawDeleteAccount(id), { retries: 2, backoffMs: 200 }));
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
export async function updateTransaction(id, fields) {
|
|
506
|
+
observability.incrementToolCall('actual.transactions.update').catch(() => { });
|
|
507
|
+
// Use write queue to batch concurrent updates in a single budget session
|
|
508
|
+
return queueWriteOperation(async () => {
|
|
509
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
export async function updateTransactionBatch(updates) {
|
|
513
|
+
observability.incrementToolCall('actual.transactions.updateBatch').catch(() => { });
|
|
514
|
+
// All updates share one queueWriteOperation → one init/sync/shutdown cycle (issue #79).
|
|
515
|
+
// Sequential loop (not Promise.all) is intentional: concurrent rawUpdateTransaction calls
|
|
516
|
+
// within one session can interleave withMutation CRDT messages unpredictably.
|
|
517
|
+
return queueWriteOperation(async () => {
|
|
518
|
+
const succeeded = [];
|
|
519
|
+
const failed = [];
|
|
520
|
+
for (const { id, fields } of updates) {
|
|
521
|
+
try {
|
|
522
|
+
await withConcurrency(() => retry(() => rawUpdateTransaction(id, fields), { retries: 0, backoffMs: 200 }));
|
|
523
|
+
succeeded.push({ id });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
527
|
+
failed.push({ id, error: message });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return { succeeded, failed };
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
export async function deleteTransaction(id) {
|
|
534
|
+
observability.incrementToolCall('actual.transactions.delete').catch(() => { });
|
|
535
|
+
return queueWriteOperation(async () => {
|
|
536
|
+
await withConcurrency(() => retry(() => rawDeleteTransaction(id), { retries: 2, backoffMs: 200 }));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
export async function updateCategory(id, fields) {
|
|
540
|
+
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
541
|
+
return queueWriteOperation(async () => {
|
|
542
|
+
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200 }));
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
export async function deleteCategory(id) {
|
|
546
|
+
observability.incrementToolCall('actual.categories.delete').catch(() => { });
|
|
547
|
+
return queueWriteOperation(async () => {
|
|
548
|
+
// Pre-flight: verify category exists to avoid ECONNRESET on missing id (BUG-1)
|
|
549
|
+
const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
|
|
550
|
+
const exists = categories.some((c) => c.id === id);
|
|
551
|
+
if (!exists) {
|
|
552
|
+
throw new Error(`Category "${id}" not found. Use actual_categories_get to list available categories.`);
|
|
553
|
+
}
|
|
554
|
+
await withConcurrency(() => retry(() => rawDeleteCategory(id), { retries: 0, backoffMs: 200 }));
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
export async function updatePayee(id, fields) {
|
|
558
|
+
observability.incrementToolCall('actual.payees.update').catch(() => { });
|
|
559
|
+
return queueWriteOperation(async () => {
|
|
560
|
+
const fieldsObj = fields;
|
|
561
|
+
// Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
|
|
562
|
+
// The "default category" feature is implemented via rules (condition: payee is X → action: set category).
|
|
563
|
+
// Passing `category` to rawUpdatePayee would cause: "Field 'category' does not exist on table payees".
|
|
564
|
+
let categoryValue = undefined; // undefined = not provided
|
|
565
|
+
let directFields = fieldsObj;
|
|
566
|
+
if ('category' in fieldsObj) {
|
|
567
|
+
categoryValue = fieldsObj.category;
|
|
568
|
+
const { category: _stripped, ...rest } = fieldsObj;
|
|
569
|
+
directFields = rest;
|
|
570
|
+
}
|
|
571
|
+
// Update the payee's direct fields (name, transfer_acct, etc.)
|
|
572
|
+
if (Object.keys(directFields).length > 0) {
|
|
573
|
+
await withConcurrency(() => retry(() => rawUpdatePayee(id, directFields), { retries: 2, backoffMs: 200 }));
|
|
574
|
+
}
|
|
575
|
+
// Handle category via the rules mechanism (same approach Actual Budget uses internally)
|
|
576
|
+
if (categoryValue !== undefined) {
|
|
577
|
+
const existingRules = await withConcurrency(() => retry(() => rawGetPayeeRules(id), { retries: 2, backoffMs: 200 }));
|
|
578
|
+
// Find an existing "set category" action rule for this payee
|
|
579
|
+
const setCategoryRule = existingRules.find((rule) => Array.isArray(rule.actions) &&
|
|
580
|
+
rule.actions.some((a) => a.op === 'set' && a.field === 'category'));
|
|
581
|
+
if (setCategoryRule) {
|
|
582
|
+
if (categoryValue === null) {
|
|
583
|
+
// null = clear the default category — delete the rule
|
|
584
|
+
await withConcurrency(() => retry(() => rawDeleteRule(setCategoryRule.id), { retries: 0, backoffMs: 200 }));
|
|
585
|
+
logger.debug(`[UPDATE PAYEE] Cleared default category rule for payee ${id}`);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
// Update existing rule's category action value
|
|
589
|
+
const updatedRule = {
|
|
590
|
+
...setCategoryRule,
|
|
591
|
+
actions: setCategoryRule.actions.map((a) => a.op === 'set' && a.field === 'category' ? { ...a, value: categoryValue } : a),
|
|
592
|
+
};
|
|
593
|
+
await withConcurrency(() => retry(() => rawUpdateRule(updatedRule), { retries: 0, backoffMs: 200 }));
|
|
594
|
+
logger.debug(`[UPDATE PAYEE] Updated default category rule for payee ${id} to category ${categoryValue}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else if (categoryValue !== null) {
|
|
598
|
+
// Create a new "set category" rule for this payee
|
|
599
|
+
const newRule = {
|
|
600
|
+
stage: null,
|
|
601
|
+
conditionsOp: 'and',
|
|
602
|
+
conditions: [{ op: 'is', field: 'payee', value: id }],
|
|
603
|
+
actions: [{ op: 'set', field: 'category', value: categoryValue }],
|
|
604
|
+
};
|
|
605
|
+
await withConcurrency(() => retry(() => rawCreateRule(newRule), { retries: 0, backoffMs: 200 }));
|
|
606
|
+
logger.debug(`[UPDATE PAYEE] Created default category rule for payee ${id} with category ${categoryValue}`);
|
|
607
|
+
}
|
|
608
|
+
// category=null + no existing rule = no-op (already clear)
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
export async function deletePayee(id) {
|
|
613
|
+
observability.incrementToolCall('actual.payees.delete').catch(() => { });
|
|
614
|
+
return queueWriteOperation(async () => {
|
|
615
|
+
// Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
|
|
616
|
+
const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
617
|
+
const exists = payees.some((p) => p.id === id);
|
|
618
|
+
if (!exists) {
|
|
619
|
+
throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
|
|
620
|
+
}
|
|
621
|
+
await withConcurrency(() => retry(() => rawDeletePayee(id), { retries: 0, backoffMs: 200 }));
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
export async function getRules() {
|
|
625
|
+
return withActualApi(async () => {
|
|
626
|
+
observability.incrementToolCall('actual.rules.get').catch(() => { });
|
|
627
|
+
const raw = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
|
|
628
|
+
return Array.isArray(raw) ? raw : [];
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
export async function createRule(rule) {
|
|
632
|
+
observability.incrementToolCall('actual.rules.create').catch(() => { });
|
|
633
|
+
return queueWriteOperation(async () => {
|
|
634
|
+
const raw = await withConcurrency(() => retry(() => rawCreateRule(rule), { retries: 2, backoffMs: 200 }));
|
|
635
|
+
const id = normalizeToId(raw);
|
|
636
|
+
return id;
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
export async function updateRule(id, fields) {
|
|
640
|
+
observability.incrementToolCall('actual.rules.update').catch(() => { });
|
|
641
|
+
return queueWriteOperation(async () => {
|
|
642
|
+
// The Actual Budget API validation requires conditions and actions arrays to exist
|
|
643
|
+
// We must fetch the existing rule and merge with the update fields
|
|
644
|
+
const rules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
|
|
645
|
+
const existingRule = rules.find((r) => r.id === id);
|
|
646
|
+
if (!existingRule) {
|
|
647
|
+
throw new Error(`Rule with id ${id} not found`);
|
|
648
|
+
}
|
|
649
|
+
const fieldsObj = fields;
|
|
650
|
+
const rule = {
|
|
651
|
+
id,
|
|
652
|
+
stage: fieldsObj.stage ?? existingRule.stage,
|
|
653
|
+
conditionsOp: fieldsObj.conditionsOp ?? existingRule.conditionsOp,
|
|
654
|
+
conditions: fieldsObj.conditions ?? existingRule.conditions ?? [],
|
|
655
|
+
actions: fieldsObj.actions ?? existingRule.actions ?? [],
|
|
656
|
+
};
|
|
657
|
+
logger.debug(`[UPDATE RULE] Updating rule ${id} with merged fields: ${JSON.stringify(rule)}`);
|
|
658
|
+
await withConcurrency(() => retry(() => rawUpdateRule(rule), { retries: 0, backoffMs: 200 }));
|
|
659
|
+
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
export async function deleteRule(id) {
|
|
663
|
+
observability.incrementToolCall('actual.rules.delete').catch(() => { });
|
|
664
|
+
return queueWriteOperation(async () => {
|
|
665
|
+
await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
export async function getSchedules() {
|
|
669
|
+
return withActualApi(async () => {
|
|
670
|
+
observability.incrementToolCall('actual.schedules.get').catch(() => { });
|
|
671
|
+
const raw = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
|
|
672
|
+
return Array.isArray(raw) ? raw : [];
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
export async function createSchedule(schedule) {
|
|
676
|
+
observability.incrementToolCall('actual.schedules.create').catch(() => { });
|
|
677
|
+
return queueWriteOperation(async () => {
|
|
678
|
+
// Note: rawCreateSchedule(schedule) passes the external schedule object directly.
|
|
679
|
+
// Do NOT wrap in { schedule: ... } — that would double-nest and break date parsing.
|
|
680
|
+
const raw = await withConcurrency(() => retry(() => rawCreateSchedule(schedule), { retries: 0, backoffMs: 200 }));
|
|
681
|
+
const id = normalizeToId(raw);
|
|
682
|
+
return id;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
export async function updateSchedule(id, fields, resetNextDate) {
|
|
686
|
+
observability.incrementToolCall('actual.schedules.update').catch(() => { });
|
|
687
|
+
return queueWriteOperation(async () => {
|
|
688
|
+
await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200 }));
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
export async function deleteSchedule(id) {
|
|
692
|
+
observability.incrementToolCall('actual.schedules.delete').catch(() => { });
|
|
693
|
+
return queueWriteOperation(async () => {
|
|
694
|
+
await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
export async function setBudgetCarryover(month, categoryId, flag) {
|
|
698
|
+
observability.incrementToolCall('actual.budgets.setCarryover').catch(() => { });
|
|
699
|
+
return queueWriteOperation(async () => {
|
|
700
|
+
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200 }));
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
export async function closeAccount(id) {
|
|
704
|
+
observability.incrementToolCall('actual.accounts.close').catch(() => { });
|
|
705
|
+
return queueWriteOperation(async () => {
|
|
706
|
+
await withConcurrency(() => retry(() => rawCloseAccount(id), { retries: 2, backoffMs: 200 }));
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
export async function reopenAccount(id) {
|
|
710
|
+
observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
|
|
711
|
+
return queueWriteOperation(async () => {
|
|
712
|
+
await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
export async function getCategoryGroups() {
|
|
716
|
+
return withActualApi(async () => {
|
|
717
|
+
observability.incrementToolCall('actual.category_groups.get').catch(() => { });
|
|
718
|
+
const raw = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
|
|
719
|
+
return Array.isArray(raw) ? raw : [];
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
export async function createCategoryGroup(group) {
|
|
723
|
+
observability.incrementToolCall('actual.category_groups.create').catch(() => { });
|
|
724
|
+
return queueWriteOperation(async () => {
|
|
725
|
+
const raw = await withConcurrency(() => retry(() => rawCreateCategoryGroup(group), { retries: 2, backoffMs: 200 }));
|
|
726
|
+
const id = normalizeToId(raw);
|
|
727
|
+
return id;
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
export async function updateCategoryGroup(id, fields) {
|
|
731
|
+
observability.incrementToolCall('actual.category_groups.update').catch(() => { });
|
|
732
|
+
return queueWriteOperation(async () => {
|
|
733
|
+
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200 }));
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
export async function deleteCategoryGroup(id) {
|
|
737
|
+
observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
|
|
738
|
+
return queueWriteOperation(async () => {
|
|
739
|
+
await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 2, backoffMs: 200 }));
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
export async function mergePayees(targetId, mergeIds) {
|
|
743
|
+
observability.incrementToolCall('actual.payees.merge').catch(() => { });
|
|
744
|
+
return queueWriteOperation(async () => {
|
|
745
|
+
await withConcurrency(() => retry(() => rawMergePayees(targetId, mergeIds), { retries: 2, backoffMs: 200 }));
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
export async function getPayeeRules(payeeId) {
|
|
749
|
+
return withActualApi(async () => {
|
|
750
|
+
observability.incrementToolCall('actual.payees.getPayeeRules').catch(() => { });
|
|
751
|
+
const allRules = await withConcurrency(() => retry(() => rawGetPayeeRules(payeeId), { retries: 2, backoffMs: 200 }));
|
|
752
|
+
if (!Array.isArray(allRules))
|
|
753
|
+
return [];
|
|
754
|
+
// API ignores payeeId filter — apply post-filter (BUG-3)
|
|
755
|
+
return allRules.filter((r) => r?.payee_id === payeeId);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
export async function batchBudgetUpdates(fn) {
|
|
759
|
+
observability.incrementToolCall('actual.budgets.batchUpdates').catch(() => { });
|
|
760
|
+
return queueWriteOperation(async () => {
|
|
761
|
+
await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
export async function holdBudgetForNextMonth(month, amount) {
|
|
765
|
+
observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
|
|
766
|
+
return queueWriteOperation(async () => {
|
|
767
|
+
await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 2, backoffMs: 200 }));
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
export async function resetBudgetHold(month) {
|
|
771
|
+
observability.incrementToolCall('actual.budgets.resetHold').catch(() => { });
|
|
772
|
+
return queueWriteOperation(async () => {
|
|
773
|
+
await withConcurrency(() => retry(() => rawResetBudgetHold(month), { retries: 2, backoffMs: 200 }));
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
export async function runQuery(queryString) {
|
|
777
|
+
try {
|
|
778
|
+
return await withActualApi(async () => {
|
|
779
|
+
observability.incrementToolCall('actual.query.run').catch(() => { });
|
|
780
|
+
try {
|
|
781
|
+
// Import validation utilities
|
|
782
|
+
const { validateQuery, formatValidationErrors } = await import('./query-validator.js');
|
|
783
|
+
// The Actual Budget runQuery expects an ActualQL query object with serialize() method
|
|
784
|
+
// Import the q builder dynamically
|
|
785
|
+
const api = await import('@actual-app/api');
|
|
786
|
+
const q = api.q;
|
|
787
|
+
if (!q) {
|
|
788
|
+
throw new Error('ActualQL query builder not available. Ensure @actual-app/api is properly installed and the budget is loaded.');
|
|
789
|
+
}
|
|
790
|
+
// If already a serialized query object, use it directly
|
|
791
|
+
if (typeof queryString === 'object' && queryString !== null) {
|
|
792
|
+
try {
|
|
793
|
+
return await withConcurrency(async () => {
|
|
794
|
+
try {
|
|
795
|
+
return await rawRunQuery(queryString);
|
|
796
|
+
}
|
|
797
|
+
catch (err) {
|
|
798
|
+
// Catch errors from the query execution to prevent unhandled rejections
|
|
799
|
+
const msg = err?.message || String(err);
|
|
800
|
+
logger.error(`[ADAPTER] Query execution error: ${msg}`);
|
|
801
|
+
if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
|
|
802
|
+
throw new Error(`Invalid field in query: ${msg}`);
|
|
803
|
+
}
|
|
804
|
+
throw err;
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
catch (error) {
|
|
809
|
+
throw new Error(`Query execution failed: ${error.message}`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const trimmed = queryString.trim();
|
|
813
|
+
let query;
|
|
814
|
+
// Check for GraphQL-like query syntax: query Name { table(...) { fields } }
|
|
815
|
+
const graphqlMatch = trimmed.match(/^query\s+\w+\s*\{\s*(\w+)\s*\(([^)]*)\)\s*\{([^}]+)\}\s*\}$/is);
|
|
816
|
+
if (graphqlMatch) {
|
|
817
|
+
const [, tableName, argsStr, fieldsStr] = graphqlMatch;
|
|
818
|
+
query = q(tableName);
|
|
819
|
+
// Parse arguments (e.g., startDate: "2025-06-01", endDate: "2025-11-30")
|
|
820
|
+
if (argsStr.trim()) {
|
|
821
|
+
const args = argsStr.split(',').map((a) => a.trim());
|
|
822
|
+
for (const arg of args) {
|
|
823
|
+
const argMatch = arg.match(/^(\w+):\s*"([^"]+)"$/);
|
|
824
|
+
if (argMatch) {
|
|
825
|
+
const [, key, value] = argMatch;
|
|
826
|
+
// Map GraphQL args to ActualQL filters
|
|
827
|
+
if (key === 'startDate') {
|
|
828
|
+
query = query.filter({ date: { $gte: value } });
|
|
829
|
+
}
|
|
830
|
+
else if (key === 'endDate') {
|
|
831
|
+
query = query.filter({ date: { $lte: value } });
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
// Generic filter for other args
|
|
835
|
+
query = query.filter({ [key]: value });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Parse fields (including nested objects like account { id name })
|
|
841
|
+
const fieldNames = [];
|
|
842
|
+
const nestedFieldPattern = /(\w+)\s*\{[^}]+\}/g;
|
|
843
|
+
const simpleFields = fieldsStr.replace(nestedFieldPattern, '').split(/\s+/).filter((f) => f.trim());
|
|
844
|
+
fieldNames.push(...simpleFields.map((f) => f.trim()));
|
|
845
|
+
// Extract nested field names (e.g., account, payee, category)
|
|
846
|
+
let nestedMatch;
|
|
847
|
+
while ((nestedMatch = nestedFieldPattern.exec(fieldsStr)) !== null) {
|
|
848
|
+
fieldNames.push(nestedMatch[1]);
|
|
849
|
+
}
|
|
850
|
+
if (fieldNames.length > 0) {
|
|
851
|
+
query = query.select(fieldNames);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
// Enhanced SQL-like parsing supporting WHERE, ORDER BY, and LIMIT
|
|
856
|
+
// Pattern: SELECT [fields] FROM table [WHERE conditions] [ORDER BY field ASC|DESC] [LIMIT n]
|
|
857
|
+
const sqlMatch = trimmed.match(/^SELECT\s+(.+?)\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(\w+)(?:\s+(ASC|DESC))?)?(?:\s+LIMIT\s+(\d+))?$/is);
|
|
858
|
+
if (sqlMatch) {
|
|
859
|
+
const [, fields, tableName, whereClause, orderField, orderDir, limitStr] = sqlMatch;
|
|
860
|
+
// ✅ VALIDATE QUERY BEFORE EXECUTION
|
|
861
|
+
const validation = validateQuery(trimmed);
|
|
862
|
+
if (!validation.valid) {
|
|
863
|
+
const errorMsg = formatValidationErrors(validation);
|
|
864
|
+
throw new Error(`Invalid SQL query:\n${errorMsg}\n\nQuery: "${trimmed}"`);
|
|
865
|
+
}
|
|
866
|
+
query = q(tableName);
|
|
867
|
+
// Apply SELECT fields (if not *)
|
|
868
|
+
if (fields.trim() !== '*') {
|
|
869
|
+
// Strip SQL aliases (AS alias_name) since ActualQL doesn't support them
|
|
870
|
+
const fieldList = fields.split(',').map((f) => {
|
|
871
|
+
const field = f.trim();
|
|
872
|
+
// Remove "AS alias" part if present (case-insensitive)
|
|
873
|
+
return field.replace(/\s+AS\s+\w+$/i, '').trim();
|
|
874
|
+
});
|
|
875
|
+
query = query.select(fieldList);
|
|
876
|
+
}
|
|
877
|
+
// Apply WHERE conditions
|
|
878
|
+
if (whereClause) {
|
|
879
|
+
query = parseWhereClause(query, whereClause);
|
|
880
|
+
}
|
|
881
|
+
// Apply ORDER BY
|
|
882
|
+
if (orderField) {
|
|
883
|
+
query = query.orderBy({ [orderField]: orderDir?.toUpperCase() === 'DESC' ? 'desc' : 'asc' });
|
|
884
|
+
}
|
|
885
|
+
// Apply LIMIT
|
|
886
|
+
if (limitStr) {
|
|
887
|
+
query = query.limit(parseInt(limitStr));
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
// Assume it's just a table name
|
|
892
|
+
query = q(trimmed);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
return await withConcurrency(async () => {
|
|
897
|
+
try {
|
|
898
|
+
return await rawRunQuery(query);
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
// Catch errors from the query execution to prevent unhandled rejections
|
|
902
|
+
const msg = err?.message || String(err);
|
|
903
|
+
logger.error(`[ADAPTER] Query execution error: ${msg}`);
|
|
904
|
+
if (msg.includes('does not exist in table') || msg.includes('Field') || msg.includes('does not exist')) {
|
|
905
|
+
throw new Error(`Invalid field in query: ${msg}`);
|
|
906
|
+
}
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
// Enhance error messages with helpful context
|
|
913
|
+
const errorMsg = error?.message || String(error);
|
|
914
|
+
// If the error already contains formatted validation errors (with suggestions), preserve them
|
|
915
|
+
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
916
|
+
throw error; // Re-throw the well-formatted validation error as-is
|
|
917
|
+
}
|
|
918
|
+
if (errorMsg.includes('does not exist in the schema') || errorMsg.includes('Invalid field in query') || errorMsg.includes('does not exist in table')) {
|
|
919
|
+
throw new Error(`Table or field does not exist. Query: "${trimmed}". Available tables: transactions, accounts, categories, payees, category_groups, schedules, rules. Use dot notation for joins (e.g., payee.name, category.name). Original error: ${errorMsg}`);
|
|
920
|
+
}
|
|
921
|
+
// Re-throw with original error if no specific handling
|
|
922
|
+
throw error;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
// Outer catch for query parsing errors
|
|
927
|
+
const errorMsg = error?.message || String(error);
|
|
928
|
+
if (errorMsg.includes('tableName') || errorMsg.includes('expandStar') || errorMsg.includes('Cannot read properties of undefined')) {
|
|
929
|
+
throw new Error(`SQL query parsing failed. The Actual Budget query engine has limitations with complex SQL features like COUNT(*), SUM(), GROUP BY, and aggregate functions. Try using simpler queries or ActualQL format instead. See https://actualbudget.org/docs/api/actual-ql/ for supported syntax. Error: ${errorMsg}`);
|
|
930
|
+
}
|
|
931
|
+
throw error;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
catch (error) {
|
|
936
|
+
// Top-level catch to ensure no unhandled rejections escape
|
|
937
|
+
const errorMsg = error?.message || String(error);
|
|
938
|
+
logger.error(`[ADAPTER] Query execution failed: ${errorMsg}`);
|
|
939
|
+
// If the error already contains formatted validation errors with suggestions, preserve them
|
|
940
|
+
if (errorMsg.includes('Invalid SQL query:') && (errorMsg.includes('Available fields:') || errorMsg.includes('Available tables:'))) {
|
|
941
|
+
throw error; // Re-throw the well-formatted validation error without wrapping
|
|
942
|
+
}
|
|
943
|
+
throw new Error(`Query execution failed: ${errorMsg}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Helper function to parse WHERE clause conditions.
|
|
947
|
+
// Exported so it can be unit-tested directly.
|
|
948
|
+
export function parseWhereClause(query, whereClause) {
|
|
949
|
+
// Split by AND (simple parser - doesn't handle OR or nested conditions)
|
|
950
|
+
const conditions = whereClause.split(/\s+AND\s+/i);
|
|
951
|
+
for (const condition of conditions) {
|
|
952
|
+
const trimmedCondition = condition.trim();
|
|
953
|
+
// Handle IN clause: field IN (value1, value2, ...)
|
|
954
|
+
// [\w.]+ matches both simple fields (amount) and joined fields (category.name)
|
|
955
|
+
const inMatch = trimmedCondition.match(/^([\w.]+)\s+IN\s+\((.+)\)$/i);
|
|
956
|
+
if (inMatch) {
|
|
957
|
+
const [, field, valuesStr] = inMatch;
|
|
958
|
+
const values = valuesStr.split(',').map(v => {
|
|
959
|
+
const trimmed = v.trim().replace(/^['"]|['"]$/g, '');
|
|
960
|
+
// Try to parse as number, otherwise keep as string
|
|
961
|
+
const num = Number(trimmed);
|
|
962
|
+
return isNaN(num) ? trimmed : num;
|
|
963
|
+
});
|
|
964
|
+
query = query.filter({ [field]: { $oneof: values } });
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
// Handle comparison operators: field >= value, field <= value, field = value, etc.
|
|
968
|
+
// [\w.]+ matches both simple fields (amount) and joined fields (category.name, payee.name)
|
|
969
|
+
const compMatch = trimmedCondition.match(/^([\w.]+)\s*(>=|<=|>|<|=|!=)\s*(.+)$/);
|
|
970
|
+
if (compMatch) {
|
|
971
|
+
const [, field, operator, valueStr] = compMatch;
|
|
972
|
+
const value = valueStr.trim().replace(/^['"]|['"]$/g, '');
|
|
973
|
+
// Map SQL operators to ActualQL operators
|
|
974
|
+
const operatorMap = {
|
|
975
|
+
'>=': '$gte',
|
|
976
|
+
'<=': '$lte',
|
|
977
|
+
'>': '$gt',
|
|
978
|
+
'<': '$lt',
|
|
979
|
+
'=': '$eq',
|
|
980
|
+
'!=': '$ne',
|
|
981
|
+
};
|
|
982
|
+
const actualOp = operatorMap[operator];
|
|
983
|
+
if (actualOp) {
|
|
984
|
+
// Try to parse as number if possible
|
|
985
|
+
const numValue = Number(value);
|
|
986
|
+
const finalValue = isNaN(numValue) ? value : numValue;
|
|
987
|
+
if (actualOp === '$eq') {
|
|
988
|
+
// Simple equality can use direct field: value
|
|
989
|
+
query = query.filter({ [field]: finalValue });
|
|
990
|
+
}
|
|
991
|
+
else {
|
|
992
|
+
query = query.filter({ [field]: { [actualOp]: finalValue } });
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return query;
|
|
998
|
+
}
|
|
999
|
+
export async function runBankSync(accountId) {
|
|
1000
|
+
try {
|
|
1001
|
+
return await withActualApi(async () => {
|
|
1002
|
+
observability.incrementToolCall('actual.bank.sync').catch(() => { });
|
|
1003
|
+
// Bank sync must NOT be retried — retrying could import duplicate transactions.
|
|
1004
|
+
// Pass { accountId } for a specific account, or {} to sync all linked accounts.
|
|
1005
|
+
const args = accountId != null ? { accountId } : {};
|
|
1006
|
+
// Pre-check: verify bank-linked accounts exist before calling rawRunBankSync.
|
|
1007
|
+
// The SDK silently resolves void for local accounts (account_sync_source: null),
|
|
1008
|
+
// which would otherwise be misreported as success and cause an unnecessary 30s wait.
|
|
1009
|
+
if (accountId != null) {
|
|
1010
|
+
// Per-account check: verify the specified account is bank-linked.
|
|
1011
|
+
const { data: acctRows } = await rawRunQuery(api.q('accounts')
|
|
1012
|
+
.select(['account_sync_source', 'name'])
|
|
1013
|
+
.filter({ id: accountId, tombstone: false }));
|
|
1014
|
+
const acct = acctRows?.[0];
|
|
1015
|
+
if (!acct) {
|
|
1016
|
+
throw new Error(`Bank sync failed: Account not found (id: ${accountId})`);
|
|
1017
|
+
}
|
|
1018
|
+
if (!acct.account_sync_source) {
|
|
1019
|
+
throw new Error(`Bank sync failed: Account "${acct.name}" is a local account — not configured for bank sync. ` +
|
|
1020
|
+
`To use bank sync, link your account with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
|
|
1021
|
+
`See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
// Global check: verify at least one bank-linked account exists across the budget.
|
|
1026
|
+
const { data: allAccounts } = await rawRunQuery(api.q('accounts')
|
|
1027
|
+
.select(['account_sync_source'])
|
|
1028
|
+
.filter({ tombstone: false }));
|
|
1029
|
+
const linkedCount = allAccounts?.filter(a => a.account_sync_source).length ?? 0;
|
|
1030
|
+
if (linkedCount === 0) {
|
|
1031
|
+
throw new Error(`Bank sync failed: No accounts are configured for bank sync. ` +
|
|
1032
|
+
`To use bank sync, link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
|
|
1033
|
+
`See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// rawRunBankSync returns void immediately; the actual provider call runs on
|
|
1037
|
+
// a background promise inside the SDK and surfaces errors as unhandledRejection.
|
|
1038
|
+
// We install a temporary listener to capture any BankSyncError and re-throw.
|
|
1039
|
+
let capturedRejection = null;
|
|
1040
|
+
const rejectionHandler = (reason) => {
|
|
1041
|
+
const msg = reason?.message || String(reason);
|
|
1042
|
+
if (reason?.type === 'BankSyncError' ||
|
|
1043
|
+
msg.includes('BankSyncError') ||
|
|
1044
|
+
msg.includes('NORDIGEN_ERROR') ||
|
|
1045
|
+
msg.includes('Rate limit exceeded') ||
|
|
1046
|
+
msg.includes('Failed syncing account') ||
|
|
1047
|
+
msg.includes('GoCardless') ||
|
|
1048
|
+
msg.includes('SimpleFIN')) {
|
|
1049
|
+
capturedRejection = reason;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
process.on('unhandledRejection', rejectionHandler);
|
|
1053
|
+
try {
|
|
1054
|
+
await rawRunBankSync(args);
|
|
1055
|
+
// Wait for the SDK's background promise to resolve/reject.
|
|
1056
|
+
// Provider errors (rate limits, auth failures) arrive in < 3s in practice;
|
|
1057
|
+
// BANK_SYNC_SETTLE_MS gives a comfortable margin to catch them.
|
|
1058
|
+
await new Promise(resolve => setTimeout(resolve, BANK_SYNC_SETTLE_MS));
|
|
1059
|
+
if (capturedRejection !== null)
|
|
1060
|
+
throw capturedRejection;
|
|
1061
|
+
}
|
|
1062
|
+
finally {
|
|
1063
|
+
process.off('unhandledRejection', rejectionHandler);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
const errorMsg = error?.message || String(error);
|
|
1069
|
+
// Network / connectivity errors (includes "fetch failed" from Node.js native fetch)
|
|
1070
|
+
if (errorMsg.includes('fetch failed') ||
|
|
1071
|
+
errorMsg.includes('network-failure') ||
|
|
1072
|
+
errorMsg.includes('ECONNREFUSED') ||
|
|
1073
|
+
errorMsg.includes('ENOTFOUND') ||
|
|
1074
|
+
errorMsg.includes('Authentication failed')) {
|
|
1075
|
+
throw new Error(`Bank sync failed: Cannot connect to Actual Budget server. ` +
|
|
1076
|
+
`Check that ACTUAL_SERVER_URL is reachable from the MCP server container. (${errorMsg})`);
|
|
1077
|
+
}
|
|
1078
|
+
// Account not configured for bank sync
|
|
1079
|
+
if (errorMsg.includes('No bank account') ||
|
|
1080
|
+
errorMsg.includes('not configured') ||
|
|
1081
|
+
errorMsg.includes('not linked') ||
|
|
1082
|
+
!errorMsg ||
|
|
1083
|
+
errorMsg === '{}') {
|
|
1084
|
+
throw new Error(`Bank sync failed: The ${accountId ? 'specified account is' : 'accounts are'} not configured for bank sync. ` +
|
|
1085
|
+
`To use bank sync, you must first link your account(s) with a supported provider (GoCardless or SimpleFIN) in the Actual Budget UI. ` +
|
|
1086
|
+
`See https://actualbudget.org/docs/advanced/bank-sync for setup instructions.`);
|
|
1087
|
+
}
|
|
1088
|
+
// GoCardless / SimpleFIN provider-level errors
|
|
1089
|
+
// BankSyncError objects (from @actual-app/api) may have { type, category, code, message }
|
|
1090
|
+
const bankSyncCategory = error?.category || '';
|
|
1091
|
+
if (errorMsg.includes('Rate limit exceeded') ||
|
|
1092
|
+
errorMsg.includes('RATE_LIMIT_EXCEEDED') ||
|
|
1093
|
+
bankSyncCategory === 'RATE_LIMIT_EXCEEDED') {
|
|
1094
|
+
const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
|
|
1095
|
+
const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
|
|
1096
|
+
throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
|
|
1097
|
+
`(NORDIGEN RATE_LIMIT_EXCEEDED — account success quota exhausted)`);
|
|
1098
|
+
}
|
|
1099
|
+
if (error?.type === 'BankSyncError' ||
|
|
1100
|
+
errorMsg.includes('BankSyncError') ||
|
|
1101
|
+
errorMsg.includes('NORDIGEN_ERROR') ||
|
|
1102
|
+
errorMsg.includes('Failed syncing account')) {
|
|
1103
|
+
throw new Error(`Bank sync failed: Provider error — ${errorMsg}`);
|
|
1104
|
+
}
|
|
1105
|
+
throw new Error(`Bank sync failed: ${errorMsg}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
export async function getBudgets() {
|
|
1109
|
+
return withActualApi(async () => {
|
|
1110
|
+
observability.incrementToolCall('actual.budgets.getAll').catch(() => { });
|
|
1111
|
+
const raw = await withConcurrency(() => retry(() => rawGetBudgets(), { retries: 2, backoffMs: 200 }));
|
|
1112
|
+
return Array.isArray(raw) ? raw : [];
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Switch the active budget by name (case-insensitive, partial match supported).
|
|
1117
|
+
* The budget must be pre-configured via BUDGET_n_NAME env vars.
|
|
1118
|
+
* Switching is instant — no API call required; the next withActualApi
|
|
1119
|
+
* call will automatically connect to the new budget's server and sync ID.
|
|
1120
|
+
*/
|
|
1121
|
+
export function switchBudget(name) {
|
|
1122
|
+
const key = name.toLowerCase();
|
|
1123
|
+
let found = budgetRegistry.get(key);
|
|
1124
|
+
let foundKey = key;
|
|
1125
|
+
if (!found) {
|
|
1126
|
+
// Partial / substring match
|
|
1127
|
+
for (const [k, v] of budgetRegistry) {
|
|
1128
|
+
if (k.includes(key) || key.includes(k)) {
|
|
1129
|
+
found = v;
|
|
1130
|
+
foundKey = k;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (!found) {
|
|
1136
|
+
const available = [...budgetRegistry.values()].map(b => `"${b.name}"`).join(', ');
|
|
1137
|
+
throw new Error(`Budget "${name}" not found in configuration. ` +
|
|
1138
|
+
`Available budgets: ${available}. ` +
|
|
1139
|
+
`Add BUDGET_n_NAME/SYNC_ID/SERVER_URL vars to configure additional budgets.`);
|
|
1140
|
+
}
|
|
1141
|
+
activeBudgetKey = foundKey;
|
|
1142
|
+
logger.info(`[ADAPTER] Active budget switched to: "${found.name}" (${found.syncId}) on ${found.serverUrl}`);
|
|
1143
|
+
return { name: found.name, syncId: found.syncId, serverUrl: found.serverUrl };
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Return all configured budgets from the registry (for listing in actual_budgets_list_available).
|
|
1147
|
+
*/
|
|
1148
|
+
export function getBudgetRegistry() {
|
|
1149
|
+
return [...budgetRegistry.values()].map(b => ({
|
|
1150
|
+
name: b.name,
|
|
1151
|
+
syncId: b.syncId,
|
|
1152
|
+
serverUrl: b.serverUrl,
|
|
1153
|
+
hasEncryption: !!b.encryptionPassword,
|
|
1154
|
+
}));
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Get the UUID for any Account, Payee, Category or Schedule by name.
|
|
1158
|
+
* Allowed types: 'accounts', 'schedules', 'categories', 'payees'
|
|
1159
|
+
*/
|
|
1160
|
+
export async function getIDByName(type, name) {
|
|
1161
|
+
return withActualApi(async () => {
|
|
1162
|
+
observability.incrementToolCall('actual.getIDByName').catch(() => { });
|
|
1163
|
+
return await withConcurrency(() => retry(() => rawGetIDByName(type, name), { retries: 2, backoffMs: 200 }));
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Get the current Actual Budget server version.
|
|
1168
|
+
* Returns { version: string } on success, { error: string } on failure.
|
|
1169
|
+
*/
|
|
1170
|
+
export async function getServerVersion() {
|
|
1171
|
+
return withActualApi(async () => {
|
|
1172
|
+
observability.incrementToolCall('actual.getServerVersion').catch(() => { });
|
|
1173
|
+
return await withConcurrency(() => retry(() => rawGetServerVersion(), { retries: 2, backoffMs: 200 }));
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
export default {
|
|
1177
|
+
getAccounts,
|
|
1178
|
+
getAccountsWithBalances,
|
|
1179
|
+
addTransactions,
|
|
1180
|
+
importTransactions,
|
|
1181
|
+
getTransactions,
|
|
1182
|
+
getCategories,
|
|
1183
|
+
createCategory,
|
|
1184
|
+
getPayees,
|
|
1185
|
+
createPayee,
|
|
1186
|
+
getBudgetMonths,
|
|
1187
|
+
getBudgetMonth,
|
|
1188
|
+
setBudgetAmount,
|
|
1189
|
+
createAccount,
|
|
1190
|
+
updateAccount,
|
|
1191
|
+
getAccountBalance,
|
|
1192
|
+
deleteAccount,
|
|
1193
|
+
updateTransaction,
|
|
1194
|
+
deleteTransaction,
|
|
1195
|
+
updateCategory,
|
|
1196
|
+
deleteCategory,
|
|
1197
|
+
updatePayee,
|
|
1198
|
+
deletePayee,
|
|
1199
|
+
getRules,
|
|
1200
|
+
createRule,
|
|
1201
|
+
updateRule,
|
|
1202
|
+
deleteRule,
|
|
1203
|
+
setBudgetCarryover,
|
|
1204
|
+
closeAccount,
|
|
1205
|
+
reopenAccount,
|
|
1206
|
+
getCategoryGroups,
|
|
1207
|
+
createCategoryGroup,
|
|
1208
|
+
updateCategoryGroup,
|
|
1209
|
+
deleteCategoryGroup,
|
|
1210
|
+
mergePayees,
|
|
1211
|
+
getPayeeRules,
|
|
1212
|
+
batchBudgetUpdates,
|
|
1213
|
+
holdBudgetForNextMonth,
|
|
1214
|
+
resetBudgetHold,
|
|
1215
|
+
runQuery,
|
|
1216
|
+
runBankSync,
|
|
1217
|
+
getBudgets,
|
|
1218
|
+
switchBudget,
|
|
1219
|
+
getBudgetRegistry,
|
|
1220
|
+
getIDByName,
|
|
1221
|
+
getServerVersion,
|
|
1222
|
+
getSchedules,
|
|
1223
|
+
createSchedule,
|
|
1224
|
+
updateSchedule,
|
|
1225
|
+
deleteSchedule,
|
|
1226
|
+
updateTransactionBatch,
|
|
1227
|
+
notifications,
|
|
1228
|
+
};
|