actual-mcp-server 0.11.6 → 0.13.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/README.md +12 -6
- package/dist/src/lib/actual-adapter.js +345 -7
- package/dist/src/tools/accounts_close.js +60 -5
- package/dist/src/tools/accounts_reopen.js +18 -2
- package/dist/src/tools/budgets_holdForNextMonth.js +37 -6
- package/dist/src/tools/budgets_setAmount.js +26 -6
- package/dist/src/tools/payees_delete.js +5 -2
- package/dist/src/tools/payees_merge.js +41 -5
- package/dist/src/tools/rules_delete.js +23 -3
- package/dist/src/tools/transactions_create.js +3 -3
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -312,10 +312,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
312
312
|
|------|-------------|
|
|
313
313
|
| `actual_accounts_list` | List all accounts |
|
|
314
314
|
| `actual_accounts_create` | Create new account |
|
|
315
|
-
| `actual_accounts_update` | Update account details |
|
|
315
|
+
| `actual_accounts_update` | Update account details. An id that does not exist is refused rather than creating a partial record |
|
|
316
316
|
| `actual_accounts_delete` | Permanently delete account |
|
|
317
|
-
| `actual_accounts_close` | Close account
|
|
318
|
-
| `actual_accounts_reopen` | Reopen closed account |
|
|
317
|
+
| `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
|
|
318
|
+
| `actual_accounts_reopen` | Reopen closed account. An id that is not an account is refused rather than creating one |
|
|
319
319
|
| `actual_accounts_get_balance` | Get account balance at a date |
|
|
320
320
|
|
|
321
321
|
### Transactions (14)
|
|
@@ -406,10 +406,10 @@ For Claude Desktop (stdio), restart Claude after upgrading.
|
|
|
406
406
|
| `actual_budgets_get_all` | List available budget files |
|
|
407
407
|
| `actual_budgets_getMonths` | List budget months |
|
|
408
408
|
| `actual_budgets_getMonth` | Get budget for a specific month |
|
|
409
|
-
| `actual_budgets_setAmount` | Set category budget amount |
|
|
409
|
+
| `actual_budgets_setAmount` | Set category budget amount. The month must be one the budget has (see `actual_budgets_getMonths`) |
|
|
410
410
|
| `actual_budgets_transfer` | Transfer amount between categories |
|
|
411
411
|
| `actual_budgets_setCarryover` | Enable/disable carryover |
|
|
412
|
-
| `actual_budgets_holdForNextMonth` | Hold funds for next month |
|
|
412
|
+
| `actual_budgets_holdForNextMonth` | Hold funds for next month. Actual clamps the hold to what is left to budget, so the response reports the amount actually held |
|
|
413
413
|
| `actual_budgets_resetHold` | Reset hold status |
|
|
414
414
|
| `actual_budgets_export` | Export the active budget as a `.zip` into `ACTUAL_EXPORT_DIR`; returns path, byte size and sha256, never the file contents |
|
|
415
415
|
| `actual_budgets_import` | Restore a budget from an Actual `.zip` or a YNAB4/YNAB5 export. **Destructive:** the budget id comes from the archive, so re-importing an export *replaces* that budget's data rather than making a copy. Also loads the imported budget, changing the session's active budget |
|
|
@@ -774,4 +774,10 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
774
774
|
|
|
775
775
|
---
|
|
776
776
|
|
|
777
|
-
**Version:** 0.
|
|
777
|
+
**Version:** 0.13.0 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
|
+
|
|
779
|
+
## Sponsor
|
|
780
|
+
|
|
781
|
+
I build and maintain this in my own time. It is free, it stays free, and it gets maintained either way.
|
|
782
|
+
|
|
783
|
+
If it saved you some time and you feel like saying thanks, you can do that at [github.com/sponsors/agigante80](https://github.com/sponsors/agigante80). Entirely optional, and nothing about the project changes either way.
|
|
@@ -816,6 +816,27 @@ export async function addTransactions(txs, options = {}) {
|
|
|
816
816
|
if (!accountId) {
|
|
817
817
|
throw new Error('Transaction must include account or accountId');
|
|
818
818
|
}
|
|
819
|
+
// #359: upstream never validates the account. `addTransactions`
|
|
820
|
+
// (loot-core/src/server/accounts/sync.ts) takes acctId, normalises, runs rules and
|
|
821
|
+
// inserts, and `api/transactions-add` returns the string 'ok' unconditionally. Rule
|
|
822
|
+
// evaluation does not rescue us either: prepareTransactionForRules resolves the
|
|
823
|
+
// account with `r._account?.name || ''`. So a bogus account id produced rows with a
|
|
824
|
+
// dangling `account` column that NO listing tool can return (they all filter by
|
|
825
|
+
// account), that sync to every other client, and the tool reported success.
|
|
826
|
+
//
|
|
827
|
+
// This is the same guard `createTransfer` has performed since it was written, using
|
|
828
|
+
// the same single accounts read inside the same write cycle. A CLOSED account is
|
|
829
|
+
// deliberately still allowed: importing history into a closed account is legitimate,
|
|
830
|
+
// which is why this diverges from createTransfer, where a closed account would be an
|
|
831
|
+
// odd transfer destination.
|
|
832
|
+
const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
833
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
834
|
+
const accountExists = accounts.some((a) => a?.id === accountId);
|
|
835
|
+
if (!accountExists) {
|
|
836
|
+
throw new Error(`Account "${accountId}" not found. Use actual_accounts_list to find valid accounts. ` +
|
|
837
|
+
'No transactions were created: Actual would otherwise have written them against an ' +
|
|
838
|
+
'account that does not exist, where no tool could retrieve them.');
|
|
839
|
+
}
|
|
819
840
|
// Remove account/accountId from transaction objects as they're passed separately
|
|
820
841
|
const cleanedTxs = txArray.map(tx => {
|
|
821
842
|
const { account, accountId: _, ...rest } = tx;
|
|
@@ -978,6 +999,21 @@ export async function setBudgetAmount(month, categoryId, amount) {
|
|
|
978
999
|
if (!exists) {
|
|
979
1000
|
throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
|
|
980
1001
|
}
|
|
1002
|
+
// #361: the MONTH is unvalidated, in format AND range. `api/budget-set-amount` is the
|
|
1003
|
+
// one budget handler that does not call upstream's `validateMonth`, unlike
|
|
1004
|
+
// budget-set-carryover, budget-hold-for-next-month and budget-reset-hold, and the
|
|
1005
|
+
// tool's schema is a bare `z.string().min(1)`, so even 'banana' reaches this point.
|
|
1006
|
+
// Upstream then runs `dbMonth(month)` and INSERTs a row keyed `<month>-<category>`.
|
|
1007
|
+
//
|
|
1008
|
+
// A membership test against the budget's own months covers both halves at once:
|
|
1009
|
+
// upstream's `validateMonth` and `api/budget-months` share the same
|
|
1010
|
+
// `get-budget-bounds()` plus `range()` computation, so this reproduces its range check
|
|
1011
|
+
// exactly, and a malformed string cannot be a member either.
|
|
1012
|
+
const months = await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
|
|
1013
|
+
if (Array.isArray(months) && months.length > 0 && !months.includes(String(month))) {
|
|
1014
|
+
throw new Error(`Month "${month}" is not in this budget. It runs from ${months[0]} to ${months[months.length - 1]}. ` +
|
|
1015
|
+
'Use actual_budgets_getMonths to see the months you can budget to.');
|
|
1016
|
+
}
|
|
981
1017
|
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
982
1018
|
return result;
|
|
983
1019
|
});
|
|
@@ -1031,6 +1067,15 @@ export async function createAccount(account, initialBalance) {
|
|
|
1031
1067
|
export async function updateAccount(id, fields) {
|
|
1032
1068
|
observability.incrementToolCall('actual.accounts.update').catch(() => { });
|
|
1033
1069
|
return queueWriteOperation(async () => {
|
|
1070
|
+
// #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
|
|
1071
|
+
// path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
|
|
1072
|
+
// than matching nothing. Refuse first, the way updateTag and updateRule already do.
|
|
1073
|
+
// A CLOSED account is still updatable: getAccounts filters `tombstone = 0`, not
|
|
1074
|
+
// `closed = 0`, so this refuses only ids that genuinely do not exist.
|
|
1075
|
+
const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1076
|
+
if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
|
|
1077
|
+
throw new Error(notFoundMsg('Account', id, 'actual_accounts_list'));
|
|
1078
|
+
}
|
|
1034
1079
|
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1035
1080
|
return null;
|
|
1036
1081
|
});
|
|
@@ -1192,6 +1237,15 @@ export async function deleteTransaction(id) {
|
|
|
1192
1237
|
export async function updateCategory(id, fields) {
|
|
1193
1238
|
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
1194
1239
|
return queueWriteOperation(async () => {
|
|
1240
|
+
// #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
|
|
1241
|
+
// path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
|
|
1242
|
+
// than matching nothing. Refuse first, the way updateTag and updateRule already do.
|
|
1243
|
+
// Called with no argument, upstream returns every category in every group, hidden
|
|
1244
|
+
// included, so a hidden category is not misreported as missing.
|
|
1245
|
+
const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
|
|
1246
|
+
if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
|
|
1247
|
+
throw new Error(notFoundMsg('Category', id, 'actual_categories_get'));
|
|
1248
|
+
}
|
|
1195
1249
|
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1196
1250
|
});
|
|
1197
1251
|
}
|
|
@@ -1210,6 +1264,20 @@ export async function deleteCategory(id) {
|
|
|
1210
1264
|
export async function updatePayee(id, fields) {
|
|
1211
1265
|
observability.incrementToolCall('actual.payees.update').catch(() => { });
|
|
1212
1266
|
return queueWriteOperation(async () => {
|
|
1267
|
+
// #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
|
|
1268
|
+
// path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
|
|
1269
|
+
// than matching nothing. Refuse first, the way updateTag and updateRule already do.
|
|
1270
|
+
// Known edge, and why it is acceptable: `db.getPayees()` excludes a transfer payee whose
|
|
1271
|
+
// linked account has been hard-tombstoned, so such an orphan is refused here as
|
|
1272
|
+
// not-found. A caller CAN hold such an id (transaction rows carry `payee`, and
|
|
1273
|
+
// actual_entities_search and actual_get_id_by_name surface ids too), so this is not
|
|
1274
|
+
// strictly unreachable. It is accepted because updating an orphaned transfer payee,
|
|
1275
|
+
// whose account no longer exists, is not a real workflow, and because the alternative
|
|
1276
|
+
// is leaving four tools able to create phantom rows.
|
|
1277
|
+
const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1278
|
+
if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
|
|
1279
|
+
throw new Error(notFoundMsg('Payee', id, 'actual_payees_get'));
|
|
1280
|
+
}
|
|
1213
1281
|
const fieldsObj = fields;
|
|
1214
1282
|
// Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
|
|
1215
1283
|
// The "default category" feature is implemented via rules (condition: payee is X → action: set category).
|
|
@@ -1267,10 +1335,26 @@ export async function deletePayee(id) {
|
|
|
1267
1335
|
return queueWriteOperation(async () => {
|
|
1268
1336
|
// Pre-flight: verify payee exists to avoid ECONNRESET on missing id (BUG-2)
|
|
1269
1337
|
const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1270
|
-
|
|
1338
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1339
|
+
const found = payees.find((p) => p.id === id);
|
|
1340
|
+
const exists = Boolean(found);
|
|
1271
1341
|
if (!exists) {
|
|
1272
1342
|
throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
|
|
1273
1343
|
}
|
|
1344
|
+
// #356: the existence check above is not enough. Actual refuses to delete a
|
|
1345
|
+
// TRANSFER payee (the payee it auto-creates for each account) and refuses
|
|
1346
|
+
// SILENTLY: `db.deletePayee` opens with `if (transfer_acct) { return; }`, so the
|
|
1347
|
+
// call returns normally having done nothing and the tool reported success.
|
|
1348
|
+
//
|
|
1349
|
+
// `getPayees()` deliberately INCLUDES transfer payees, which is why they reach
|
|
1350
|
+
// this point at all, and it selects `COALESCE(a.name, p.name) AS name`, so a
|
|
1351
|
+
// transfer payee's own `name` is already the owning account's name. No second
|
|
1352
|
+
// lookup is needed to write a useful message.
|
|
1353
|
+
if (found?.transfer_acct) {
|
|
1354
|
+
throw new Error(`Payee "${found.name ?? id}" is a TRANSFER payee: it belongs to the account of the ` +
|
|
1355
|
+
'same name and Actual will not delete it on its own. Delete the account instead ' +
|
|
1356
|
+
'with actual_accounts_delete, which removes its transfer payee too.');
|
|
1357
|
+
}
|
|
1274
1358
|
await withConcurrency(() => retry(() => rawDeletePayee(id), { retries: 0, backoffMs: 200 }));
|
|
1275
1359
|
});
|
|
1276
1360
|
}
|
|
@@ -1325,10 +1409,24 @@ export async function updateRule(id, fields) {
|
|
|
1325
1409
|
logger.debug(`[UPDATE RULE] Update completed for rule ${id}`);
|
|
1326
1410
|
});
|
|
1327
1411
|
}
|
|
1412
|
+
/**
|
|
1413
|
+
* #355: RETURNS the upstream verdict instead of discarding it, for the same reason
|
|
1414
|
+
* as `holdBudgetForNextMonth` above. Upstream `deleteRule`
|
|
1415
|
+
* (loot-core/src/server/transactions/transaction-rules.ts) returns `false` without
|
|
1416
|
+
* throwing when a schedule owns the rule, because Actual keeps a schedule and its
|
|
1417
|
+
* generated rule in step and will not let the rule be removed on its own.
|
|
1418
|
+
*
|
|
1419
|
+
* `actual_rules_delete` does not route through here (it calls the raw API inside one
|
|
1420
|
+
* `withWriteSession` cycle, per #142), so this method currently has NO caller, and the
|
|
1421
|
+
* same is true of `holdBudgetForNextMonth` below since #355 moved that tool to the same
|
|
1422
|
+
* pattern. Both are kept, and both carry the verdict, so that the next caller inherits
|
|
1423
|
+
* the correct contract instead of rediscovering this the hard way. Neither widening is
|
|
1424
|
+
* exercised by a test today, for the same reason: nothing calls them.
|
|
1425
|
+
*/
|
|
1328
1426
|
export async function deleteRule(id) {
|
|
1329
1427
|
observability.incrementToolCall('actual.rules.delete').catch(() => { });
|
|
1330
1428
|
return queueWriteOperation(async () => {
|
|
1331
|
-
await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
|
|
1429
|
+
return await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
|
|
1332
1430
|
});
|
|
1333
1431
|
}
|
|
1334
1432
|
export async function getSchedules() {
|
|
@@ -1366,17 +1464,146 @@ export async function setBudgetCarryover(month, categoryId, flag) {
|
|
|
1366
1464
|
await withConcurrency(() => retry(() => rawSetBudgetCarryover(month, categoryId, flag), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1367
1465
|
});
|
|
1368
1466
|
}
|
|
1369
|
-
|
|
1467
|
+
/**
|
|
1468
|
+
* #357, at the adapter layer since #371.
|
|
1469
|
+
*
|
|
1470
|
+
* Three upstream behaviours have to be handled together, because any one of them rewrites
|
|
1471
|
+
* this function:
|
|
1472
|
+
*
|
|
1473
|
+
* (a) `closeAccount` opens with `if (!account || account.closed === 1) return;`, so an
|
|
1474
|
+
* unknown id or an already-closed account did nothing and reported success.
|
|
1475
|
+
* (b) `if (numTransactions === 0) await db.deleteAccount({id})`: an account with no
|
|
1476
|
+
* transactions is TOMBSTONED, not closed, and cannot be reopened.
|
|
1477
|
+
* (c) a non-zero balance throws `balance is non-zero: transferAccountId is required`, and
|
|
1478
|
+
* the transfer arguments are documented but were not exposed until #357.
|
|
1479
|
+
*
|
|
1480
|
+
* Read, write and re-read share ONE `queueWriteOperation` cycle. A read-BEFORE is required
|
|
1481
|
+
* (unlike #347's pure verify-after) because (b) makes "absent afterwards" ambiguous: an
|
|
1482
|
+
* account deleted by the close and an id that never existed look identical from a single
|
|
1483
|
+
* post-read.
|
|
1484
|
+
*
|
|
1485
|
+
* WHY THIS LIVES HERE AND NOT IN THE TOOL. It was in the tool until #371. Keeping the read
|
|
1486
|
+
* and the write in one cycle does NOT require the raw api: this is the shape
|
|
1487
|
+
* `mergePayees` already used, and doing it here keeps `retry` on the reads, keeps ONE
|
|
1488
|
+
* observability call site, and leaves no unguarded `adapter.closeAccount` for a future
|
|
1489
|
+
* caller to reach for. The tool maps the outcome below onto its response wording.
|
|
1490
|
+
*/
|
|
1491
|
+
export async function closeAccount(id, transferAccountId, transferCategoryId) {
|
|
1370
1492
|
observability.incrementToolCall('actual.accounts.close').catch(() => { });
|
|
1371
1493
|
return queueWriteOperation(async () => {
|
|
1372
|
-
|
|
1373
|
-
|
|
1494
|
+
const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1495
|
+
const list = Array.isArray(before) ? before : [];
|
|
1496
|
+
const target = list.find((a) => a?.id === id);
|
|
1497
|
+
if (!target) {
|
|
1498
|
+
throw new Error(`Account "${id}" not found. Use actual_accounts_list to see the accounts that exist. ` +
|
|
1499
|
+
'Note that an account closed while it had no transactions was REMOVED by Actual, ' +
|
|
1500
|
+
'not closed, so it will not appear there.');
|
|
1501
|
+
}
|
|
1502
|
+
if (target.closed === true) {
|
|
1503
|
+
// Idempotent and truthful: the requested state already holds, and the caller is told
|
|
1504
|
+
// that nothing changed rather than being implied a state change happened.
|
|
1505
|
+
return { outcome: 'already-closed', name: target.name };
|
|
1506
|
+
}
|
|
1507
|
+
if (transferAccountId) {
|
|
1508
|
+
const destination = list.find((a) => a?.id === transferAccountId);
|
|
1509
|
+
if (!destination) {
|
|
1510
|
+
throw new Error(`Transfer destination account "${transferAccountId}" not found. Use ` +
|
|
1511
|
+
'actual_accounts_list to pick an account to move the remaining balance to.');
|
|
1512
|
+
}
|
|
1513
|
+
if (destination.closed === true) {
|
|
1514
|
+
throw new Error(`Transfer destination account "${destination.name ?? transferAccountId}" is CLOSED, ` +
|
|
1515
|
+
'so the closing balance would be moved somewhere hidden from most views. Pick an ' +
|
|
1516
|
+
'open account, or reopen that one with actual_accounts_reopen first.');
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (transferCategoryId) {
|
|
1520
|
+
// #359's lesson applied to the only WRITE this path can add. Upstream forwards this id
|
|
1521
|
+
// straight into `transaction-add` unchecked, so a bogus value writes a "Closing
|
|
1522
|
+
// account" transaction carrying a category that does not exist, and syncs it.
|
|
1523
|
+
const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
|
|
1524
|
+
const known = Array.isArray(categories) && categories.some((c) => c?.id === transferCategoryId);
|
|
1525
|
+
if (!known) {
|
|
1526
|
+
throw new Error(`Transfer category "${transferCategoryId}" not found. Use actual_categories_get to ` +
|
|
1527
|
+
'pick a category for the closing transaction, or omit transferCategoryId to leave ' +
|
|
1528
|
+
'it uncategorised.');
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
try {
|
|
1532
|
+
// Non-idempotent: do not retry (#165).
|
|
1533
|
+
await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
|
|
1534
|
+
}
|
|
1535
|
+
catch (err) {
|
|
1536
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1537
|
+
if (/balance is non-zero/i.test(msg)) {
|
|
1538
|
+
throw new Error(`Account "${target.name ?? id}" has a non-zero balance, so closing it requires ` +
|
|
1539
|
+
'transferAccountId: the id of the account to move the remaining balance to. ' +
|
|
1540
|
+
'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
|
|
1541
|
+
'Use actual_accounts_list to pick a destination.');
|
|
1542
|
+
}
|
|
1543
|
+
throw err;
|
|
1544
|
+
}
|
|
1545
|
+
const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1546
|
+
const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
|
|
1547
|
+
if (!survivor) {
|
|
1548
|
+
// (b): upstream tombstoned a zero-transaction account instead of closing it.
|
|
1549
|
+
return { outcome: 'removed', name: target.name };
|
|
1550
|
+
}
|
|
1551
|
+
if (survivor.closed !== true) {
|
|
1552
|
+
throw new Error(`Account "${survivor.name ?? id}" (${id}) is still open after the close. The call was ` +
|
|
1553
|
+
'accepted but had no effect; check the account state in Actual.');
|
|
1554
|
+
}
|
|
1555
|
+
return { outcome: 'closed', name: survivor.name };
|
|
1374
1556
|
});
|
|
1375
1557
|
}
|
|
1558
|
+
/**
|
|
1559
|
+
* #358, at the adapter layer since #371.
|
|
1560
|
+
*
|
|
1561
|
+
* Upstream `reopenAccount` is a bare `db.update('accounts', {id, closed: 0})`. `db.update`
|
|
1562
|
+
* does not run a SQL UPDATE: it sends CRDT messages, and the apply path INSERTs when the
|
|
1563
|
+
* row was absent. The accounts table has no NOT NULL columns and `tombstone` defaults to 0,
|
|
1564
|
+
* while `getAccounts()` filters `tombstone = 0`, so reopening an id that is not an account
|
|
1565
|
+
* CREATES a visible nameless account that syncs to every client.
|
|
1566
|
+
*
|
|
1567
|
+
* A pre-check is right here and was wrong in #347: there, the caller's intent ("make this
|
|
1568
|
+
* account not exist") could already be satisfied by an absent row, so refusing on absence
|
|
1569
|
+
* would have failed a complete request. Here the intent ("reopen this account") cannot be
|
|
1570
|
+
* satisfied by an absent row, and proceeding actively creates one.
|
|
1571
|
+
*
|
|
1572
|
+
* TWO DELIBERATE LIMITS, carried over from the tool-layer version so they are not
|
|
1573
|
+
* rediscovered as oversights.
|
|
1574
|
+
*
|
|
1575
|
+
* One `queueWriteOperation` keeps this read, write and re-read in a single api lock cycle.
|
|
1576
|
+
* It does NOT serialise against other operations in the same batch: `processWriteQueue`
|
|
1577
|
+
* dispatches with `Promise.allSettled`, so operations queued in the same drain window
|
|
1578
|
+
* interleave at await points. The cycle excludes other SESSIONS, not batch siblings.
|
|
1579
|
+
*
|
|
1580
|
+
* The not-found message covers both reasons an id can be missing (never existed, or removed
|
|
1581
|
+
* by a close while it had no transactions) rather than distinguishing them. `q().withDead()`
|
|
1582
|
+
* would tell them apart by reading tombstoned rows, at the cost of an extra query on the
|
|
1583
|
+
* failure path. Collapsing them was chosen because the message names both cases and the
|
|
1584
|
+
* remedy is the same either way.
|
|
1585
|
+
*/
|
|
1376
1586
|
export async function reopenAccount(id) {
|
|
1377
1587
|
observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
|
|
1378
1588
|
return queueWriteOperation(async () => {
|
|
1589
|
+
const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1590
|
+
const target = (Array.isArray(before) ? before : []).find((a) => a?.id === id);
|
|
1591
|
+
if (!target) {
|
|
1592
|
+
throw new Error(`Account "${id}" not found, so it cannot be reopened. Use actual_accounts_list to see ` +
|
|
1593
|
+
'the accounts that exist. If this account was closed while it had no transactions, ' +
|
|
1594
|
+
'Actual removed it rather than closing it, and it cannot be reopened: create a new ' +
|
|
1595
|
+
'one with actual_accounts_create.');
|
|
1596
|
+
}
|
|
1379
1597
|
await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
|
|
1598
|
+
const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
|
|
1599
|
+
const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
|
|
1600
|
+
if (!survivor) {
|
|
1601
|
+
throw new Error(`Account "${id}" disappeared while being reopened. Check the account state in Actual.`);
|
|
1602
|
+
}
|
|
1603
|
+
if (survivor.closed === true) {
|
|
1604
|
+
throw new Error(`Account "${survivor.name ?? id}" (${id}) is still closed after the reopen. The call ` +
|
|
1605
|
+
'was accepted but had no effect; check the account state in Actual.');
|
|
1606
|
+
}
|
|
1380
1607
|
});
|
|
1381
1608
|
}
|
|
1382
1609
|
export async function getCategoryGroups() {
|
|
@@ -1397,6 +1624,16 @@ export async function createCategoryGroup(group) {
|
|
|
1397
1624
|
export async function updateCategoryGroup(id, fields) {
|
|
1398
1625
|
observability.incrementToolCall('actual.category_groups.update').catch(() => { });
|
|
1399
1626
|
return queueWriteOperation(async () => {
|
|
1627
|
+
// #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
|
|
1628
|
+
// path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
|
|
1629
|
+
// than matching nothing. Refuse first, the way updateTag and updateRule already do.
|
|
1630
|
+
// Called with no argument, upstream returns all groups including hidden ones, so a
|
|
1631
|
+
// hidden group is not misreported as missing (the same reason category_groups_delete
|
|
1632
|
+
// can rely on this listing).
|
|
1633
|
+
const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
|
|
1634
|
+
if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
|
|
1635
|
+
throw new Error(notFoundMsg('Category group', id, 'actual_category_groups_get'));
|
|
1636
|
+
}
|
|
1400
1637
|
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1401
1638
|
});
|
|
1402
1639
|
}
|
|
@@ -1407,12 +1644,72 @@ export async function deleteCategoryGroup(id) {
|
|
|
1407
1644
|
await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
|
|
1408
1645
|
});
|
|
1409
1646
|
}
|
|
1647
|
+
/**
|
|
1648
|
+
* #356: bound what an error message echoes back. The caller supplies these ids, they end
|
|
1649
|
+
* up in the tool response AND in `logger.error` via actualToolsManager, and `mergeIds`
|
|
1650
|
+
* accepts up to 50 of them at 64 characters each. Naming a handful is enough to act on;
|
|
1651
|
+
* naming all of them is an unbounded echo of caller-controlled input.
|
|
1652
|
+
*/
|
|
1653
|
+
function summariseIds(ids, limit = 5) {
|
|
1654
|
+
const shown = ids.slice(0, limit).map((id) => (id.length > 64 ? `${id.slice(0, 64)}...` : id));
|
|
1655
|
+
const rest = ids.length - shown.length;
|
|
1656
|
+
return rest > 0 ? `${shown.join(', ')} (and ${rest} more)` : shown.join(', ');
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* #356: merge, with the pre-flight it never had.
|
|
1660
|
+
*
|
|
1661
|
+
* Upstream `db.mergePayees` fails silently in two ways and throws unhelpfully in a
|
|
1662
|
+
* third:
|
|
1663
|
+
*
|
|
1664
|
+
* if (payees[target].transfer_acct != null) { return; } // silent no-op
|
|
1665
|
+
* ids = ids.filter(id => payees[id].transfer_acct == null); // sources dropped
|
|
1666
|
+
*
|
|
1667
|
+
* A transfer-payee target makes the whole call a no-op; transfer-payee sources are
|
|
1668
|
+
* dropped from the list without a word, while the tool still reported "merged N
|
|
1669
|
+
* payee(s)" using the length of its own INPUT. And an id that does not exist at all
|
|
1670
|
+
* is indexed straight off the map, so it surfaces as
|
|
1671
|
+
* `TypeError: Cannot read properties of undefined (reading 'transfer_acct')`.
|
|
1672
|
+
*
|
|
1673
|
+
* One `getPayees()` read inside this same write cycle answers all three. It returns the
|
|
1674
|
+
* de-duplicated ids it ACCEPTED for merge, which after those three refusals is the set
|
|
1675
|
+
* upstream will act on. Note what it is not: there is no post-write read here, unlike
|
|
1676
|
+
* close, reopen and hold, so a drop reason nobody has enumerated would still go unnoticed.
|
|
1677
|
+
*/
|
|
1410
1678
|
export async function mergePayees(targetId, mergeIds) {
|
|
1411
1679
|
observability.incrementToolCall('actual.payees.merge').catch(() => { });
|
|
1412
1680
|
return queueWriteOperation(async () => {
|
|
1681
|
+
const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
|
|
1682
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1683
|
+
const byId = new Map(payees.map((p) => [p.id, p]));
|
|
1684
|
+
const target = byId.get(targetId);
|
|
1685
|
+
if (!target) {
|
|
1686
|
+
throw new Error(`Payee "${targetId}" not found. Use actual_payees_get to list available payees.`);
|
|
1687
|
+
}
|
|
1688
|
+
if (target.transfer_acct) {
|
|
1689
|
+
throw new Error(`Target payee "${target.name ?? targetId}" is a TRANSFER payee and cannot be merged ` +
|
|
1690
|
+
'into. Actual silently ignores such a merge. Pick a normal payee as the target.');
|
|
1691
|
+
}
|
|
1692
|
+
const missing = mergeIds.filter((id) => !byId.has(id));
|
|
1693
|
+
if (missing.length > 0) {
|
|
1694
|
+
throw new Error(`Payee(s) not found: ${summariseIds(missing)}. Use actual_payees_get to list available payees.`);
|
|
1695
|
+
}
|
|
1696
|
+
const transfers = mergeIds.filter((id) => byId.get(id)?.transfer_acct);
|
|
1697
|
+
if (transfers.length > 0) {
|
|
1698
|
+
const names = transfers.map((id) => String(byId.get(id)?.name ?? id));
|
|
1699
|
+
throw new Error(`Cannot merge TRANSFER payee(s): ${summariseIds(names)}. They belong to accounts of the ` +
|
|
1700
|
+
'same name, and Actual silently drops them from a merge rather than merging them. ' +
|
|
1701
|
+
'Remove them from mergeIds, or delete the account with actual_accounts_delete.');
|
|
1702
|
+
}
|
|
1703
|
+
if (mergeIds.includes(targetId)) {
|
|
1704
|
+
throw new Error(`Payee "${targetId}" appears as both the merge target and a merge source.`);
|
|
1705
|
+
}
|
|
1706
|
+
// De-duplicate before reporting, or `['p','p']` claims two merges for one. The whole
|
|
1707
|
+
// point of returning ids is that the count describes what happened.
|
|
1708
|
+
const unique = [...new Set(mergeIds)];
|
|
1413
1709
|
// Non-idempotent: do not retry (#165). A second merge against an
|
|
1414
1710
|
// already-removed source payee can corrupt merge state or mislead.
|
|
1415
|
-
await withConcurrency(() => retry(() => rawMergePayees(targetId,
|
|
1711
|
+
await withConcurrency(() => retry(() => rawMergePayees(targetId, unique), { retries: 0, backoffMs: 200 }));
|
|
1712
|
+
return unique;
|
|
1416
1713
|
});
|
|
1417
1714
|
}
|
|
1418
1715
|
/**
|
|
@@ -1463,10 +1760,51 @@ export async function batchBudgetUpdates(fn) {
|
|
|
1463
1760
|
await withConcurrency(() => retry(() => rawBatchBudgetUpdates(fn), { retries: 2, backoffMs: 200 }));
|
|
1464
1761
|
});
|
|
1465
1762
|
}
|
|
1763
|
+
/**
|
|
1764
|
+
* #355: RETURNS the upstream verdict instead of discarding it.
|
|
1765
|
+
*
|
|
1766
|
+
* Upstream `holdForNextMonth` (loot-core/src/server/budget/actions.ts) returns a
|
|
1767
|
+
* BOOLEAN: `true` when it buffered the amount, `false` when the month's To Budget
|
|
1768
|
+
* is not positive and it held nothing. It does not throw in the second case. The
|
|
1769
|
+
* published reference documents the method as `Promise<null>`, which is why the
|
|
1770
|
+
* value was being thrown away here and the tool reported success for a hold that
|
|
1771
|
+
* never happened (CWE-252, unchecked return value).
|
|
1772
|
+
*
|
|
1773
|
+
* `undefined` is deliberately NOT treated as a refusal: an older or future build
|
|
1774
|
+
* that returns nothing must keep working. Only an explicit `false` is a verdict.
|
|
1775
|
+
*/
|
|
1776
|
+
/**
|
|
1777
|
+
* #355, at the adapter layer since #371.
|
|
1778
|
+
*
|
|
1779
|
+
* Upstream has TWO ways of doing less than asked, and only one of them is a boolean:
|
|
1780
|
+
*
|
|
1781
|
+
* holdForNextMonth: if (toBudget > 0) { ...; return true; } return false;
|
|
1782
|
+
* calcBufferedAmount: amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
|
|
1783
|
+
*
|
|
1784
|
+
* So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
|
|
1785
|
+
* 100.00 with 30.00 left to budget and 30.00 is held, silently. Reading `forNextMonth`
|
|
1786
|
+
* before and after settles both from observed state, which is the #347 principle and is
|
|
1787
|
+
* strictly better than trusting either return value.
|
|
1788
|
+
*
|
|
1789
|
+
* Returns the amount ACTUALLY held. The caller compares it with what it requested.
|
|
1790
|
+
*/
|
|
1466
1791
|
export async function holdBudgetForNextMonth(month, amount) {
|
|
1467
1792
|
observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
|
|
1468
1793
|
return queueWriteOperation(async () => {
|
|
1469
|
-
|
|
1794
|
+
// `isRetryable` matters here, unlike most read sites: upstream's `api/budget-month` opens
|
|
1795
|
+
// with `validateMonth` and throws for an out-of-range month. Without a classifier,
|
|
1796
|
+
// retry() retries EVERY rejection (see retry.ts), so a deterministic domain error would
|
|
1797
|
+
// cost three upstream calls and 600ms of sleeping inside the api mutex, against the one
|
|
1798
|
+
// withOpTimeout budget this whole callback shares. #177 doctrine: domain errors are
|
|
1799
|
+
// terminal.
|
|
1800
|
+
const before = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1801
|
+
const heldBefore = Number(before?.forNextMonth ?? 0);
|
|
1802
|
+
// Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
|
|
1803
|
+
// (`return buffered + amount`), so a second attempt after a committed first does not
|
|
1804
|
+
// re-set the buffer, it adds to it.
|
|
1805
|
+
await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
|
|
1806
|
+
const after = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1807
|
+
return Number(after?.forNextMonth ?? 0) - heldBefore;
|
|
1470
1808
|
});
|
|
1471
1809
|
}
|
|
1472
1810
|
export async function resetBudgetHold(month) {
|
|
@@ -1,16 +1,71 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
const InputSchema = z
|
|
4
|
-
|
|
3
|
+
const InputSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
id: z.string().min(1).max(64).describe('Account ID to close'),
|
|
6
|
+
// #357: documented by the Actual API reference as required when the account's
|
|
7
|
+
// balance is non-zero, and previously not exposed at all, which made such an
|
|
8
|
+
// account impossible to close through this server.
|
|
9
|
+
transferAccountId: z
|
|
10
|
+
.string()
|
|
11
|
+
.min(1)
|
|
12
|
+
.max(64)
|
|
13
|
+
.optional()
|
|
14
|
+
.describe('Account ID to move the remaining balance to. REQUIRED when the account has a non-zero balance.'),
|
|
15
|
+
transferCategoryId: z
|
|
16
|
+
.string()
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(64)
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Category to assign the balancing transaction to. Optional, used with transferAccountId.'),
|
|
21
|
+
})
|
|
22
|
+
.superRefine((val, ctx) => {
|
|
23
|
+
if (val.transferAccountId && val.transferAccountId === val.id) {
|
|
24
|
+
ctx.addIssue({
|
|
25
|
+
code: z.ZodIssueCode.custom,
|
|
26
|
+
path: ['transferAccountId'],
|
|
27
|
+
message: 'transferAccountId must be a different account from the one being closed',
|
|
28
|
+
});
|
|
29
|
+
}
|
|
5
30
|
});
|
|
31
|
+
/**
|
|
32
|
+
* #357. The three defects (silent no-op on unknown or already-closed, close-deletes a
|
|
33
|
+
* zero-transaction account, and the documented transfer arguments that were not exposed)
|
|
34
|
+
* are handled in `adapter.closeAccount`, which reads, writes and re-reads in one write-queue
|
|
35
|
+
* cycle and returns WHICH of the three outcomes happened.
|
|
36
|
+
*
|
|
37
|
+
* #371 moved that logic there from this file. This tool now owns only the published schema
|
|
38
|
+
* and the response wording, which is where the rest of the tool surface keeps them.
|
|
39
|
+
*/
|
|
6
40
|
const tool = {
|
|
7
41
|
name: 'actual_accounts_close',
|
|
8
|
-
description:
|
|
42
|
+
description: 'Mark an account as closed in Actual Budget. Closed accounts are hidden from most views. ' +
|
|
43
|
+
'IMPORTANT: an account with NO transactions is REMOVED by Actual rather than closed, and cannot ' +
|
|
44
|
+
'be reopened; an account that has transactions keeps them. If the account has a non-zero balance, ' +
|
|
45
|
+
'transferAccountId is required and the remaining balance is moved there as a dated "Closing account" ' +
|
|
46
|
+
'transaction. Closing an already-closed account reports that rather than claiming a change.',
|
|
9
47
|
inputSchema: InputSchema,
|
|
10
48
|
call: async (args, _meta) => {
|
|
11
49
|
const input = InputSchema.parse(args || {});
|
|
12
|
-
await adapter.closeAccount(input.id);
|
|
13
|
-
|
|
50
|
+
const result = await adapter.closeAccount(input.id, input.transferAccountId, input.transferCategoryId);
|
|
51
|
+
const name = result.name ?? input.id;
|
|
52
|
+
switch (result.outcome) {
|
|
53
|
+
case 'already-closed':
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
alreadyClosed: true,
|
|
57
|
+
message: `Account "${name}" was already closed. Nothing changed.`,
|
|
58
|
+
};
|
|
59
|
+
case 'removed':
|
|
60
|
+
return {
|
|
61
|
+
success: true,
|
|
62
|
+
removed: true,
|
|
63
|
+
message: `Account "${name}" had no transactions, so Actual REMOVED it rather than closing ` +
|
|
64
|
+
'it. It will not appear in actual_accounts_list and cannot be reopened.',
|
|
65
|
+
};
|
|
66
|
+
default:
|
|
67
|
+
return { success: true, closed: true };
|
|
68
|
+
}
|
|
14
69
|
},
|
|
15
70
|
};
|
|
16
71
|
export default tool;
|
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
const InputSchema = z.object({
|
|
4
|
-
id
|
|
4
|
+
// Bounded, because the id is echoed into the error message and into the structured
|
|
5
|
+
// logs. Not tightened to CommonSchemas.accountId (a UUID) here: several fixtures use
|
|
6
|
+
// short non-UUID ids, and that sweep is tracked separately alongside #365.
|
|
7
|
+
id: z.string().min(1).max(64).describe('Account ID to reopen'),
|
|
5
8
|
});
|
|
9
|
+
/**
|
|
10
|
+
* #358. The guard lives in `adapter.reopenAccount`, which reads, writes and re-reads inside
|
|
11
|
+
* one write-queue cycle. See that function for why a pre-check is right here and was wrong
|
|
12
|
+
* in #347.
|
|
13
|
+
*
|
|
14
|
+
* #371 moved it there from this file. The read-then-write property never needed the raw
|
|
15
|
+
* api: doing it in the adapter keeps `retry` on the reads, keeps one observability call
|
|
16
|
+
* site, and leaves no unguarded `adapter.reopenAccount` for a future caller to reach for.
|
|
17
|
+
*/
|
|
6
18
|
const tool = {
|
|
7
19
|
name: 'actual_accounts_reopen',
|
|
8
|
-
description:
|
|
20
|
+
description: 'Reopen a previously closed account in Actual Budget. The account becomes active again and ' +
|
|
21
|
+
'visible in all views, and all historical transactions remain intact. An id that is not an ' +
|
|
22
|
+
'account is refused: Actual would otherwise create an empty account under that id. Note that ' +
|
|
23
|
+
'an account closed while it had NO transactions was removed by Actual rather than closed, and ' +
|
|
24
|
+
'cannot be reopened.',
|
|
9
25
|
inputSchema: InputSchema,
|
|
10
26
|
call: async (args, _meta) => {
|
|
11
27
|
const input = InputSchema.parse(args || {});
|
|
@@ -2,18 +2,49 @@ import { z } from 'zod';
|
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
const InputSchema = z.object({
|
|
4
4
|
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format').describe('Budget month in YYYY-MM format'),
|
|
5
|
-
|
|
5
|
+
// #355: upstream rejects a non-positive amount with
|
|
6
|
+
// APIError('Amount to hold needs to be greater than 0') BEFORE it ever reaches
|
|
7
|
+
// holdForNextMonth, so the schema rejects it here instead: same answer, no wasted
|
|
8
|
+
// round trip, and the message names the field.
|
|
9
|
+
amount: z.number().int().positive('amount must be greater than 0 (integer cents)')
|
|
10
|
+
.describe('Amount in cents to hold for next month. Must be greater than 0.'),
|
|
6
11
|
});
|
|
12
|
+
/**
|
|
13
|
+
* #355. `adapter.holdBudgetForNextMonth` reads `forNextMonth` before and after the write and
|
|
14
|
+
* returns the amount ACTUALLY held, because upstream can do less than asked in two ways: it
|
|
15
|
+
* holds nothing when the month has no positive To Budget, and it silently CLAMPS the hold to
|
|
16
|
+
* whatever is left. A boolean catches the first and misses the second.
|
|
17
|
+
*
|
|
18
|
+
* #371 moved that logic into the adapter from this file. This tool owns the schema and the
|
|
19
|
+
* wording of the three answers: held in full, held in part, held nothing.
|
|
20
|
+
*/
|
|
7
21
|
const tool = {
|
|
8
22
|
name: 'actual_budgets_holdForNextMonth',
|
|
9
|
-
description:
|
|
10
|
-
|
|
11
|
-
|
|
23
|
+
description: "Hold an amount from this month's budget to carry into next month. The amount is in cents. " +
|
|
24
|
+
'Actual can hold LESS than requested: it clamps the hold to the amount still left to budget, ' +
|
|
25
|
+
'and holds nothing at all when that is zero or negative. The response reports the amount ' +
|
|
26
|
+
'actually held and flags a partial hold, rather than reporting plain success.\n\n' +
|
|
27
|
+
'Note: This operates on the month as a whole, not on a specific category.',
|
|
12
28
|
inputSchema: InputSchema,
|
|
13
29
|
call: async (args, _meta) => {
|
|
14
30
|
const input = InputSchema.parse(args || {});
|
|
15
|
-
await adapter.holdBudgetForNextMonth(input.month, input.amount);
|
|
16
|
-
|
|
31
|
+
const held = await adapter.holdBudgetForNextMonth(input.month, input.amount);
|
|
32
|
+
if (held <= 0) {
|
|
33
|
+
throw new Error(`Nothing was held for ${input.month}: that month has no positive amount left to budget, ` +
|
|
34
|
+
'so there is nothing to carry into next month. Budget less to categories in that month, ' +
|
|
35
|
+
'or check it with actual_budgets_getMonth first.');
|
|
36
|
+
}
|
|
37
|
+
if (held < input.amount) {
|
|
38
|
+
return {
|
|
39
|
+
success: true,
|
|
40
|
+
partial: true,
|
|
41
|
+
requested: input.amount,
|
|
42
|
+
held,
|
|
43
|
+
message: `Held ${held} of the requested ${input.amount} cents for ${input.month}. Actual clamps ` +
|
|
44
|
+
'the hold to the amount still left to budget in that month.',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return { success: true, held };
|
|
17
48
|
},
|
|
18
49
|
};
|
|
19
50
|
export default tool;
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
const InputSchema = z.object({
|
|
3
|
+
const InputSchema = z.object({
|
|
4
|
+
// #361: this was a bare z.string().min(1), so 'banana' parsed and reached the API. The
|
|
5
|
+
// regex matches actual_budgets_holdForNextMonth and the other month-taking tools; the
|
|
6
|
+
// adapter additionally checks the month is one this budget actually has.
|
|
7
|
+
month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/, 'month must be in YYYY-MM format'),
|
|
8
|
+
categoryId: z.string().min(1).max(64),
|
|
9
|
+
amount: z.number().int('amount must be an integer (cents)'),
|
|
10
|
+
});
|
|
4
11
|
const tool = {
|
|
5
12
|
name: 'actual_budgets_setAmount',
|
|
6
|
-
description:
|
|
13
|
+
description: 'Set the budgeted amount for a specific category in a given month. Amount in cents ' +
|
|
14
|
+
'(e.g., 50000 = $500). Use this to allocate money to spending categories for budget planning. ' +
|
|
15
|
+
'The month must be one the budget actually has: Actual allows from three months before the ' +
|
|
16
|
+
'earliest transaction to twelve months ahead. Use actual_budgets_getMonths to see the range.',
|
|
7
17
|
inputSchema: InputSchema,
|
|
8
18
|
call: async (args, _meta) => {
|
|
9
19
|
const input = InputSchema.parse(args || {});
|
|
@@ -13,10 +23,20 @@ const tool = {
|
|
|
13
23
|
}
|
|
14
24
|
catch (error) {
|
|
15
25
|
const msg = error instanceof Error ? error.message : String(error);
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
26
|
+
const lower = msg.toLowerCase();
|
|
27
|
+
// Both PRE-FLIGHT refusals from adapter.setBudgetAmount return the same structured
|
|
28
|
+
// shape, so a caller can handle "you asked for something that does not exist" once
|
|
29
|
+
// rather than parsing two forms:
|
|
30
|
+
//
|
|
31
|
+
// Category "<id>" not found. Use actual_categories_get ... (#89)
|
|
32
|
+
// Month "<month>" is not in this budget. It runs from X to Y ... (#361)
|
|
33
|
+
//
|
|
34
|
+
// #361 added the second one, and before this it fell through to the rethrow below,
|
|
35
|
+
// so one tool answered two identical situations in two different shapes. Anything
|
|
36
|
+
// else (a genuine upstream or transport failure) still throws.
|
|
37
|
+
const isCategoryRefusal = lower.includes('not found') && lower.includes('category');
|
|
38
|
+
const isMonthRefusal = lower.includes('not in this budget');
|
|
39
|
+
if (isCategoryRefusal || isMonthRefusal) {
|
|
20
40
|
return { success: false, error: msg };
|
|
21
41
|
}
|
|
22
42
|
throw new Error(`Failed to set budget amount: ${msg}`);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
3
|
const InputSchema = z.object({
|
|
4
|
-
|
|
4
|
+
// #356: bounded, because the id is echoed into the error message and the logs.
|
|
5
|
+
id: z.string().min(1).max(64).describe('Payee ID to delete'),
|
|
5
6
|
});
|
|
6
7
|
const tool = {
|
|
7
8
|
name: 'actual_payees_delete',
|
|
8
|
-
description: 'Delete a payee from Actual Budget. Transactions using this payee will have it removed. This
|
|
9
|
+
description: 'Delete a payee from Actual Budget. Transactions using this payee will have it removed. This ' +
|
|
10
|
+
'operation cannot be undone. A TRANSFER payee (the payee Actual creates for each account, named ' +
|
|
11
|
+
'after it) cannot be deleted on its own: the call is refused and points at actual_accounts_delete.',
|
|
9
12
|
inputSchema: InputSchema,
|
|
10
13
|
call: async (args, _meta) => {
|
|
11
14
|
const input = InputSchema.parse(args || {});
|
|
@@ -1,17 +1,53 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
+
// #356: bounded on both axes. The ids are echoed back in error messages and into the
|
|
4
|
+
// structured logs, so an unbounded string or array is an unbounded echo. Actual's ids
|
|
5
|
+
// are UUIDs, so 64 characters is already generous, and no legitimate merge consolidates
|
|
6
|
+
// more than a few hundred payees in one call.
|
|
7
|
+
const PayeeIdSchema = z.string().min(1).max(64);
|
|
3
8
|
const InputSchema = z.object({
|
|
4
|
-
targetId:
|
|
5
|
-
mergeIds: z.array(
|
|
9
|
+
targetId: PayeeIdSchema.describe('ID of the target payee to merge into (this payee will be retained)'),
|
|
10
|
+
mergeIds: z.array(PayeeIdSchema).min(1).max(50)
|
|
11
|
+
.describe('Array of payee IDs to merge into the target payee (these will be consolidated)'),
|
|
6
12
|
});
|
|
7
13
|
const tool = {
|
|
8
14
|
name: 'actual_payees_merge',
|
|
9
|
-
description:
|
|
15
|
+
description: 'Merge one or more payees into a target payee. This consolidates duplicate payees by merging ' +
|
|
16
|
+
'the specified payees into the target, retaining the name of the target payee. All transactions ' +
|
|
17
|
+
'from merged payees are reassigned to the target. TRANSFER payees (the payee Actual creates for ' +
|
|
18
|
+
'each account) cannot be merged in either direction: such a merge is refused rather than ' +
|
|
19
|
+
'silently ignored. The response lists the de-duplicated ids that were ACCEPTED for merge ' +
|
|
20
|
+
'after those checks; it is not a post-write read, so verify with actual_payees_get if you ' +
|
|
21
|
+
'need proof the payees are gone.',
|
|
10
22
|
inputSchema: InputSchema,
|
|
11
23
|
call: async (args, _meta) => {
|
|
12
24
|
const input = InputSchema.parse(args || {});
|
|
13
|
-
|
|
14
|
-
|
|
25
|
+
// #356: report what HAPPENED, not what was asked for. The old message counted
|
|
26
|
+
// `input.mergeIds.length`, which is a statement about the caller's input: it said
|
|
27
|
+
// "merged 3 payees" even when Actual had silently dropped every one of them for
|
|
28
|
+
// being a transfer payee. The adapter now refuses that case outright and returns
|
|
29
|
+
// the ids it actually merged.
|
|
30
|
+
// The adapter returns the ids it merged. If a future change ever made it return
|
|
31
|
+
// something else, falling back to `input.mergeIds` would silently reinstate exactly
|
|
32
|
+
// the "report the request, not the result" bug this ticket removed, so fail loudly
|
|
33
|
+
// instead.
|
|
34
|
+
const merged = await adapter.mergePayees(input.targetId, input.mergeIds);
|
|
35
|
+
if (!Array.isArray(merged)) {
|
|
36
|
+
throw new Error('Internal: adapter.mergePayees did not report which payees were merged. Verify the ' +
|
|
37
|
+
'merge with actual_payees_get before assuming it happened.');
|
|
38
|
+
}
|
|
39
|
+
const mergedIds = merged;
|
|
40
|
+
// #356: `mergedIds` carries the full list for a caller that wants it; the human
|
|
41
|
+
// readable message names at most five, because up to 50 ids at 64 characters each
|
|
42
|
+
// is an unbounded echo into the response and the logs.
|
|
43
|
+
const shown = mergedIds.slice(0, 5).join(', ');
|
|
44
|
+
const rest = mergedIds.length - Math.min(mergedIds.length, 5);
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
mergedIds,
|
|
48
|
+
message: `Merged ${mergedIds.length} payee(s) into ${input.targetId}: ${shown}` +
|
|
49
|
+
(rest > 0 ? ` (and ${rest} more)` : ''),
|
|
50
|
+
};
|
|
15
51
|
},
|
|
16
52
|
};
|
|
17
53
|
export default tool;
|
|
@@ -5,11 +5,15 @@ import api from '@actual-app/api';
|
|
|
5
5
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
6
|
const { getRules: rawGetRules, deleteRule: rawDeleteRule } = api;
|
|
7
7
|
const InputSchema = z.object({
|
|
8
|
-
|
|
8
|
+
// Bounded for the same reason as the payee ids in #356: this value is echoed into the
|
|
9
|
+
// not-found message, into the schedule-owned refusal below, and into logger.error.
|
|
10
|
+
id: z.string().min(1).max(64).describe('Rule ID to delete'),
|
|
9
11
|
});
|
|
10
12
|
const tool = {
|
|
11
13
|
name: 'actual_rules_delete',
|
|
12
|
-
description:
|
|
14
|
+
description: 'Delete a budget rule from Actual Budget. The rule will no longer be applied to new or ' +
|
|
15
|
+
'existing transactions. This operation cannot be undone. A rule that belongs to a SCHEDULE ' +
|
|
16
|
+
'cannot be deleted on its own: the call is refused and names the schedule tool to use instead.',
|
|
13
17
|
inputSchema: InputSchema,
|
|
14
18
|
call: async (args, _meta) => {
|
|
15
19
|
const input = InputSchema.parse(args || {});
|
|
@@ -24,7 +28,23 @@ const tool = {
|
|
|
24
28
|
// error, consistent with every other delete tool's not-found behavior.
|
|
25
29
|
throw new Error(notFoundMsg('Rule', input.id, 'actual_rules_get'));
|
|
26
30
|
}
|
|
27
|
-
|
|
31
|
+
// #355: the raw call RETURNS a verdict and it used to be discarded.
|
|
32
|
+
// Upstream `deleteRule` returns `false`, without throwing, when a schedule owns
|
|
33
|
+
// this rule: Actual keeps a schedule and its generated rule in step and refuses
|
|
34
|
+
// to remove the rule on its own. The existence pre-check above cannot catch that
|
|
35
|
+
// case, because the rule genuinely exists. Reporting success there was a lie
|
|
36
|
+
// (CWE-252, unchecked return value).
|
|
37
|
+
//
|
|
38
|
+
// Only an EXPLICIT `false` is a refusal. A build that returns `undefined` (the
|
|
39
|
+
// published reference documents this method as `Promise<null>`) is treated as
|
|
40
|
+
// success, so this stays correct against older and future versions.
|
|
41
|
+
const deleted = await rawDeleteRule(input.id);
|
|
42
|
+
if (deleted === false) {
|
|
43
|
+
throw new Error(`Rule "${input.id}" belongs to a schedule and cannot be deleted on its own. ` +
|
|
44
|
+
'Actual keeps a schedule and its generated rule in step. Delete the schedule ' +
|
|
45
|
+
'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
|
|
46
|
+
'which removes this rule too.');
|
|
47
|
+
}
|
|
28
48
|
return { success: true };
|
|
29
49
|
});
|
|
30
50
|
},
|
|
@@ -51,17 +51,17 @@ const tool = {
|
|
|
51
51
|
try {
|
|
52
52
|
// Use addTransactions - it reliably creates transactions.
|
|
53
53
|
// Note: API may return "ok" string instead of a UUID depending on server version.
|
|
54
|
-
// "ok" is a valid success indicator
|
|
54
|
+
// "ok" is a valid success indicator: the transaction WAS created.
|
|
55
55
|
const result = await adapter.addTransactions(payload);
|
|
56
56
|
if (!result || result.length === 0) {
|
|
57
57
|
return {
|
|
58
58
|
success: false,
|
|
59
|
-
error: 'Failed to create transaction
|
|
59
|
+
error: 'Failed to create transaction: no result returned from API. Use actual_accounts_list to verify the account ID.',
|
|
60
60
|
id: null,
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
// The API sometimes returns a UUID and sometimes "ok" depending on server version.
|
|
64
|
-
// Both are success
|
|
64
|
+
// Both are success: "ok" means created but no ID available from this API version.
|
|
65
65
|
const maybeId = result[0] && result[0] !== 'ok' && result[0].length > 10
|
|
66
66
|
? result[0]
|
|
67
67
|
: null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/payees_delete.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"config-drift": "npm run build && node scripts/config-drift.mjs",
|
|
63
63
|
"node-version-drift": "node scripts/node-version-drift.mjs",
|
|
64
64
|
"knip": "knip",
|
|
65
|
-
"actionlint": "bash scripts/install-actionlint.sh && ./actionlint -shellcheck= -color"
|
|
65
|
+
"actionlint": "bash scripts/install-actionlint.sh && ./actionlint -shellcheck= -color",
|
|
66
|
+
"audit:write-effect": "node scripts/check-write-effect-audit.mjs"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
68
69
|
"@actual-app/api": "^26.8.1",
|