actual-mcp-server 0.12.0 → 0.14.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 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
317
  | `actual_accounts_close` | Close account. An account with NO transactions is REMOVED by Actual, not closed |
318
- | `actual_accounts_reopen` | Reopen closed account |
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,7 +774,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
774
774
 
775
775
  ---
776
776
 
777
- **Version:** 0.12.0 | **Tool Count:** 74 (verified LibreChat-compatible)
777
+ **Version:** 0.14.0 | **Tool Count:** 74 (verified LibreChat-compatible)
778
778
 
779
779
  ## Sponsor
780
780
 
@@ -11,7 +11,8 @@ import { EventEmitter } from 'events';
11
11
  import observability from '../observability.js';
12
12
  import { retry, isRetryableError } from './retry.js';
13
13
  import { withOpTimeout } from './opTimeout.js';
14
- import { notFoundMsg } from './errors.js';
14
+ import { NotFoundRefusal, OutOfRangeRefusal, constraintErrorMsg } from './errors.js';
15
+ import { findMatchingRule } from './rule-matching.js';
15
16
  import logger from '../logger.js';
16
17
  import { checkServerVersionOnce } from './server-version-guard.js';
17
18
  import config from '../config.js';
@@ -833,8 +834,7 @@ export async function addTransactions(txs, options = {}) {
833
834
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
834
835
  const accountExists = accounts.some((a) => a?.id === accountId);
835
836
  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 ' +
837
+ throw new NotFoundRefusal('Account', accountId, 'actual_accounts_list', 'No transactions were created: Actual would otherwise have written them against an ' +
838
838
  'account that does not exist, where no tool could retrieve them.');
839
839
  }
840
840
  // Remove account/accountId from transaction objects as they're passed separately
@@ -997,7 +997,28 @@ export async function setBudgetAmount(month, categoryId, amount) {
997
997
  const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
998
998
  const exists = categories.some((c) => c.id === categoryId);
999
999
  if (!exists) {
1000
- throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
1000
+ // #377: MUST be a typed refusal. `budgets_setAmount` is one of the two tools whose
1001
+ // published contract is the SHAPE (#89: an unknown category returns
1002
+ // { success: false, error }, it does not throw), and the tool decides that by type
1003
+ // now. Leaving this as a bare Error silently undid #89, and the unit test did not
1004
+ // catch it because it stubbed the adapter and threw the refusal itself, reproducing
1005
+ // the fixture on both sides. There is a real-adapter case for it now.
1006
+ throw new NotFoundRefusal('Category', String(categoryId), 'actual_categories_get');
1007
+ }
1008
+ // #361: the MONTH is unvalidated, in format AND range. `api/budget-set-amount` is the
1009
+ // one budget handler that does not call upstream's `validateMonth`, unlike
1010
+ // budget-set-carryover, budget-hold-for-next-month and budget-reset-hold, and the
1011
+ // tool's schema is a bare `z.string().min(1)`, so even 'banana' reaches this point.
1012
+ // Upstream then runs `dbMonth(month)` and INSERTs a row keyed `<month>-<category>`.
1013
+ //
1014
+ // A membership test against the budget's own months covers both halves at once:
1015
+ // upstream's `validateMonth` and `api/budget-months` share the same
1016
+ // `get-budget-bounds()` plus `range()` computation, so this reproduces its range check
1017
+ // exactly, and a malformed string cannot be a member either.
1018
+ const months = await withConcurrency(() => retry(() => rawGetBudgetMonths(), { retries: 2, backoffMs: 200 }));
1019
+ if (Array.isArray(months) && months.length > 0 && !months.includes(String(month))) {
1020
+ throw new OutOfRangeRefusal(`Month "${month}" is outside this budget's range, which runs from ${months[0]} to ` +
1021
+ `${months[months.length - 1]}. Use actual_budgets_getMonths to see the months you can budget to.`, String(month));
1001
1022
  }
1002
1023
  const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1003
1024
  return result;
@@ -1052,6 +1073,15 @@ export async function createAccount(account, initialBalance) {
1052
1073
  export async function updateAccount(id, fields) {
1053
1074
  observability.incrementToolCall('actual.accounts.update').catch(() => { });
1054
1075
  return queueWriteOperation(async () => {
1076
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1077
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1078
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1079
+ // A CLOSED account is still updatable: getAccounts filters `tombstone = 0`, not
1080
+ // `closed = 0`, so this refuses only ids that genuinely do not exist.
1081
+ const accounts = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1082
+ if (!(Array.isArray(accounts) && accounts.some((a) => a?.id === id))) {
1083
+ throw new NotFoundRefusal('Account', id, 'actual_accounts_list');
1084
+ }
1055
1085
  await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1056
1086
  return null;
1057
1087
  });
@@ -1213,6 +1243,15 @@ export async function deleteTransaction(id) {
1213
1243
  export async function updateCategory(id, fields) {
1214
1244
  observability.incrementToolCall('actual.categories.update').catch(() => { });
1215
1245
  return queueWriteOperation(async () => {
1246
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1247
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1248
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1249
+ // Called with no argument, upstream returns every category in every group, hidden
1250
+ // included, so a hidden category is not misreported as missing.
1251
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1252
+ if (!(Array.isArray(categories) && categories.some((c) => c?.id === id))) {
1253
+ throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1254
+ }
1216
1255
  await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1217
1256
  });
1218
1257
  }
@@ -1223,7 +1262,7 @@ export async function deleteCategory(id) {
1223
1262
  const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1224
1263
  const exists = categories.some((c) => c.id === id);
1225
1264
  if (!exists) {
1226
- throw new Error(`Category "${id}" not found. Use actual_categories_get to list available categories.`);
1265
+ throw new NotFoundRefusal('Category', id, 'actual_categories_get');
1227
1266
  }
1228
1267
  await withConcurrency(() => retry(() => rawDeleteCategory(id), { retries: 0, backoffMs: 200 }));
1229
1268
  });
@@ -1231,6 +1270,20 @@ export async function deleteCategory(id) {
1231
1270
  export async function updatePayee(id, fields) {
1232
1271
  observability.incrementToolCall('actual.payees.update').catch(() => { });
1233
1272
  return queueWriteOperation(async () => {
1273
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1274
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1275
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1276
+ // Known edge, and why it is acceptable: `db.getPayees()` excludes a transfer payee whose
1277
+ // linked account has been hard-tombstoned, so such an orphan is refused here as
1278
+ // not-found. A caller CAN hold such an id (transaction rows carry `payee`, and
1279
+ // actual_entities_search and actual_get_id_by_name surface ids too), so this is not
1280
+ // strictly unreachable. It is accepted because updating an orphaned transfer payee,
1281
+ // whose account no longer exists, is not a real workflow, and because the alternative
1282
+ // is leaving four tools able to create phantom rows.
1283
+ const payees = await withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 }));
1284
+ if (!(Array.isArray(payees) && payees.some((p) => p?.id === id))) {
1285
+ throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1286
+ }
1234
1287
  const fieldsObj = fields;
1235
1288
  // Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
1236
1289
  // The "default category" feature is implemented via rules (condition: payee is X → action: set category).
@@ -1292,7 +1345,7 @@ export async function deletePayee(id) {
1292
1345
  const found = payees.find((p) => p.id === id);
1293
1346
  const exists = Boolean(found);
1294
1347
  if (!exists) {
1295
- throw new Error(`Payee "${id}" not found. Use actual_payees_get to list available payees.`);
1348
+ throw new NotFoundRefusal('Payee', id, 'actual_payees_get');
1296
1349
  }
1297
1350
  // #356: the existence check above is not enough. Actual refuses to delete a
1298
1351
  // TRANSFER payee (the payee it auto-creates for each account) and refuses
@@ -1376,10 +1429,96 @@ export async function updateRule(id, fields) {
1376
1429
  * the correct contract instead of rediscovering this the hard way. Neither widening is
1377
1430
  * exercised by a test today, for the same reason: nothing calls them.
1378
1431
  */
1432
+ /**
1433
+ * Idempotent rule upsert (#376): update the rule whose conditions match, or create one.
1434
+ *
1435
+ * Moved here from `src/tools/rules_create_or_update.ts`, which ran the read and the write
1436
+ * inside its own `withWriteSession` using raw api calls. Same single-cycle property, but the
1437
+ * read now goes through `retry` and there is one observability call site.
1438
+ *
1439
+ * The caller has already validated the payload; this owns identity and merge only.
1440
+ */
1441
+ export async function upsertRule(input, stageWasSupplied) {
1442
+ observability.incrementToolCall('actual.rules.create_or_update').catch(() => { });
1443
+ return queueWriteOperation(async () => {
1444
+ const existingRules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
1445
+ const matchedRule = findMatchingRule(existingRules, input.conditions, input.conditionsOp);
1446
+ const ruleData = JSON.parse(JSON.stringify(input)); // deep clone for the API call
1447
+ if (matchedRule) {
1448
+ // The Actual Budget API expects the FULL merged rule object as one argument.
1449
+ //
1450
+ // #342: `??` is WRONG for stage. null is a MEANINGFUL value here (Actual's default
1451
+ // stage), not an absent one, so `ruleData.stage ?? existing` would silently discard
1452
+ // an explicit `stage: null` and keep the old stage, making it impossible to move a
1453
+ // rule back to the default. Decide on PRESENCE instead. That presence test used to
1454
+ // be `'stage' in ruleData`, which worked only because the tool passed its parsed
1455
+ // input straight through; it is now an explicit parameter so the meaning survives
1456
+ // the extra call boundary.
1457
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1458
+ const merged = {
1459
+ id: matchedRule.id,
1460
+ stage: stageWasSupplied ? ruleData.stage : matchedRule.stage,
1461
+ conditionsOp: ruleData.conditionsOp ?? matchedRule.conditionsOp,
1462
+ conditions: ruleData.conditions ?? matchedRule.conditions ?? [],
1463
+ actions: ruleData.actions ?? matchedRule.actions ?? [],
1464
+ };
1465
+ // Non-idempotent from upstream's point of view: do not retry the write.
1466
+ await withConcurrency(() => retry(() => rawUpdateRule(merged), { retries: 0, backoffMs: 200 }));
1467
+ return { id: matchedRule.id, created: false };
1468
+ }
1469
+ // #342: stage has no Zod default on this tool, so an omitted stage arrives as
1470
+ // undefined. Actual's validator ALWAYS runs on a create and rejects undefined with
1471
+ // `Invalid rule stage: undefined`, so the key must be present. null is the correct
1472
+ // value: Actual's default stage.
1473
+ if (ruleData.stage === undefined)
1474
+ ruleData.stage = null;
1475
+ const rawId = await withConcurrency(() => retry(() => rawCreateRule(ruleData), { retries: 0, backoffMs: 200 }));
1476
+ return { id: normalizeToId(rawId), created: true };
1477
+ });
1478
+ }
1479
+ /**
1480
+ * #376: the existence guard and the schedule-owned refusal moved here from
1481
+ * `src/tools/rules_delete.ts`, which held them inside its own `withWriteSession`.
1482
+ *
1483
+ * WHY THE ADAPTER. Doing it in the tool meant reaching past `adapter.*` for the read, so
1484
+ * the read had no `retry` at all, and it left THIS method reachable and unguarded: calling
1485
+ * it directly silently failed to delete a schedule-owned rule, which is the #355 defect the
1486
+ * tool had already fixed. See "Where a read-then-write guard belongs" in CLAUDE.md.
1487
+ *
1488
+ * Both halves stay inside ONE `queueWriteOperation`, so the read, the decision and the
1489
+ * write share a single api lock cycle (#142). That excludes other SESSIONS; it does not
1490
+ * serialise against other operations in the same drain (see `reopenAccount` for the long
1491
+ * form of that caveat).
1492
+ */
1379
1493
  export async function deleteRule(id) {
1380
1494
  observability.incrementToolCall('actual.rules.delete').catch(() => { });
1381
1495
  return queueWriteOperation(async () => {
1382
- return await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1496
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1497
+ const allRules = await withConcurrency(() => retry(() => rawGetRules(), { retries: 2, backoffMs: 200 }));
1498
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1499
+ if (!Array.isArray(allRules) || !allRules.some((r) => r?.id === id)) {
1500
+ throw new NotFoundRefusal('Rule', id, 'actual_rules_get');
1501
+ }
1502
+ // #355: the raw call RETURNS a verdict and it used to be discarded. Upstream
1503
+ // `deleteRule` returns `false`, without throwing, when a schedule owns this rule:
1504
+ // Actual keeps a schedule and its generated rule in step and refuses to remove the
1505
+ // rule on its own. The existence check above cannot catch that case, because the rule
1506
+ // genuinely exists. Reporting success there was a lie (CWE-252).
1507
+ //
1508
+ // Only an EXPLICIT `false` is a refusal. A build that returns `undefined` (the
1509
+ // published reference documents this method as `Promise<null>`) is treated as success,
1510
+ // so this stays correct against older and future versions.
1511
+ //
1512
+ // Not a PreflightRefusal: the write WAS attempted and upstream declined it. A
1513
+ // PreflightRefusal means nothing was tried. See the taxonomy in
1514
+ // .claude/skills/api-design-principles/SKILL.md.
1515
+ const deleted = await withConcurrency(() => retry(() => rawDeleteRule(id), { retries: 0, backoffMs: 200 }));
1516
+ if (deleted === false) {
1517
+ throw new Error(`Rule "${id}" belongs to a schedule and cannot be deleted on its own. ` +
1518
+ 'Actual keeps a schedule and its generated rule in step. Delete the schedule ' +
1519
+ 'instead with actual_schedules_delete (find it with actual_schedules_get), ' +
1520
+ 'which removes this rule too.');
1521
+ }
1383
1522
  });
1384
1523
  }
1385
1524
  export async function getSchedules() {
@@ -1405,10 +1544,28 @@ export async function updateSchedule(id, fields, resetNextDate) {
1405
1544
  await withConcurrency(() => retry(() => rawUpdateSchedule(id, fields, resetNextDate), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
1406
1545
  });
1407
1546
  }
1547
+ /**
1548
+ * #376: the existence guard and the constraint-error translation moved here from
1549
+ * `src/tools/schedules_delete.ts`. The translation stays wrapped tightly around the delete
1550
+ * call so the message cannot regress into the raw SQLite text.
1551
+ */
1408
1552
  export async function deleteSchedule(id) {
1409
1553
  observability.incrementToolCall('actual.schedules.delete').catch(() => { });
1410
1554
  return queueWriteOperation(async () => {
1411
- await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
1555
+ const schedules = await withConcurrency(() => retry(() => rawGetSchedules(), { retries: 2, backoffMs: 200 }));
1556
+ if (!Array.isArray(schedules) || !schedules.some((sch) => sch?.id === id)) {
1557
+ throw new NotFoundRefusal('Schedule', id, 'actual_schedules_get');
1558
+ }
1559
+ try {
1560
+ await withConcurrency(() => retry(() => rawDeleteSchedule(id), { retries: 0, backoffMs: 200 }));
1561
+ }
1562
+ catch (err) {
1563
+ const msg = err instanceof Error ? err.message : String(err);
1564
+ if (msg.includes('NOT NULL constraint') || msg.includes('messages_crdt')) {
1565
+ throw new Error(constraintErrorMsg('Schedule', id, 'actual_schedules_get'));
1566
+ }
1567
+ throw err;
1568
+ }
1412
1569
  });
1413
1570
  }
1414
1571
  export async function setBudgetCarryover(month, categoryId, flag) {
@@ -1418,27 +1575,127 @@ export async function setBudgetCarryover(month, categoryId, flag) {
1418
1575
  });
1419
1576
  }
1420
1577
  /**
1421
- * #357: forwards the two transfer arguments the published API documents.
1578
+ * #357, at the adapter layer since #371.
1579
+ *
1580
+ * Three upstream behaviours have to be handled together, because any one of them rewrites
1581
+ * this function:
1582
+ *
1583
+ * (a) `closeAccount` opens with `if (!account || account.closed === 1) return;`, so an
1584
+ * unknown id or an already-closed account did nothing and reported success.
1585
+ * (b) `if (numTransactions === 0) await db.deleteAccount({id})`: an account with no
1586
+ * transactions is TOMBSTONED, not closed, and cannot be reopened.
1587
+ * (c) a non-zero balance throws `balance is non-zero: transferAccountId is required`, and
1588
+ * the transfer arguments are documented but were not exposed until #357.
1422
1589
  *
1423
- * `closeAccount(id, transferAccountId?, transferCategoryId?)` is the documented
1424
- * signature, and upstream throws `balance is non-zero: transferAccountId is required`
1425
- * when an account with a balance is closed without one. This adapter used to pass the
1426
- * id alone, so an account with a non-zero balance could not be closed through this
1427
- * server at all: the caller was told to supply a parameter no tool accepted.
1590
+ * Read, write and re-read share ONE `queueWriteOperation` cycle. A read-BEFORE is required
1591
+ * (unlike #347's pure verify-after) because (b) makes "absent afterwards" ambiguous: an
1592
+ * account deleted by the close and an id that never existed look identical from a single
1593
+ * post-read.
1428
1594
  *
1429
- * Both remain OPTIONAL. A zero-balance account still closes with the id alone.
1595
+ * WHY THIS LIVES HERE AND NOT IN THE TOOL. It was in the tool until #371. Keeping the read
1596
+ * and the write in one cycle does NOT require the raw api: this is the shape
1597
+ * `mergePayees` already used, and doing it here keeps `retry` on the reads, keeps ONE
1598
+ * observability call site, and leaves no unguarded `adapter.closeAccount` for a future
1599
+ * caller to reach for. The tool maps the outcome below onto its response wording.
1430
1600
  */
1431
1601
  export async function closeAccount(id, transferAccountId, transferCategoryId) {
1432
1602
  observability.incrementToolCall('actual.accounts.close').catch(() => { });
1433
1603
  return queueWriteOperation(async () => {
1434
- // Non-idempotent: do not retry (#165).
1435
- await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
1604
+ const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1605
+ const list = Array.isArray(before) ? before : [];
1606
+ const target = list.find((a) => a?.id === id);
1607
+ if (!target) {
1608
+ throw new Error(`Account "${id}" not found. Use actual_accounts_list to see the accounts that exist. ` +
1609
+ 'Note that an account closed while it had no transactions was REMOVED by Actual, ' +
1610
+ 'not closed, so it will not appear there.');
1611
+ }
1612
+ if (target.closed === true) {
1613
+ // Idempotent and truthful: the requested state already holds, and the caller is told
1614
+ // that nothing changed rather than being implied a state change happened.
1615
+ return { outcome: 'already-closed', name: target.name };
1616
+ }
1617
+ if (transferAccountId) {
1618
+ const destination = list.find((a) => a?.id === transferAccountId);
1619
+ if (!destination) {
1620
+ throw new Error(`Transfer destination account "${transferAccountId}" not found. Use ` +
1621
+ 'actual_accounts_list to pick an account to move the remaining balance to.');
1622
+ }
1623
+ if (destination.closed === true) {
1624
+ throw new Error(`Transfer destination account "${destination.name ?? transferAccountId}" is CLOSED, ` +
1625
+ 'so the closing balance would be moved somewhere hidden from most views. Pick an ' +
1626
+ 'open account, or reopen that one with actual_accounts_reopen first.');
1627
+ }
1628
+ }
1629
+ if (transferCategoryId) {
1630
+ // #359's lesson applied to the only WRITE this path can add. Upstream forwards this id
1631
+ // straight into `transaction-add` unchecked, so a bogus value writes a "Closing
1632
+ // account" transaction carrying a category that does not exist, and syncs it.
1633
+ const categories = await withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 }));
1634
+ const known = Array.isArray(categories) && categories.some((c) => c?.id === transferCategoryId);
1635
+ if (!known) {
1636
+ throw new Error(`Transfer category "${transferCategoryId}" not found. Use actual_categories_get to ` +
1637
+ 'pick a category for the closing transaction, or omit transferCategoryId to leave ' +
1638
+ 'it uncategorised.');
1639
+ }
1640
+ }
1641
+ try {
1642
+ // Non-idempotent: do not retry (#165).
1643
+ await withConcurrency(() => retry(() => rawCloseAccount(id, transferAccountId, transferCategoryId), { retries: 0, backoffMs: 200 }));
1644
+ }
1645
+ catch (err) {
1646
+ const msg = err instanceof Error ? err.message : String(err);
1647
+ if (/balance is non-zero/i.test(msg)) {
1648
+ throw new Error(`Account "${target.name ?? id}" has a non-zero balance, so closing it requires ` +
1649
+ 'transferAccountId: the id of the account to move the remaining balance to. ' +
1650
+ 'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
1651
+ 'Use actual_accounts_list to pick a destination.');
1652
+ }
1653
+ throw err;
1654
+ }
1655
+ const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1656
+ const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
1657
+ if (!survivor) {
1658
+ // (b): upstream tombstoned a zero-transaction account instead of closing it.
1659
+ return { outcome: 'removed', name: target.name };
1660
+ }
1661
+ if (survivor.closed !== true) {
1662
+ throw new Error(`Account "${survivor.name ?? id}" (${id}) is still open after the close. The call was ` +
1663
+ 'accepted but had no effect; check the account state in Actual.');
1664
+ }
1665
+ return { outcome: 'closed', name: survivor.name };
1436
1666
  });
1437
1667
  }
1438
1668
  export async function reopenAccount(id) {
1439
1669
  observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
1440
1670
  return queueWriteOperation(async () => {
1671
+ const before = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1672
+ const target = (Array.isArray(before) ? before : []).find((a) => a?.id === id);
1673
+ if (!target) {
1674
+ throw new Error(`Account "${id}" not found, so it cannot be reopened. Use actual_accounts_list to see ` +
1675
+ 'the accounts that exist. If this account was closed while it had no transactions, ' +
1676
+ 'Actual removed it rather than closing it, and it cannot be reopened: create a new ' +
1677
+ 'one with actual_accounts_create.');
1678
+ }
1679
+ // #369 item 5: an already-open account needs no write. Upstream `reopenAccount` is a
1680
+ // db.update, which in Actual means a CRDT MESSAGE that syncs to every other client, so
1681
+ // issuing it for a no-op is not free: it is sync traffic and a device-state bump for a
1682
+ // change nobody made. Reporting the non-change is also the taxonomy's rule 1 (the
1683
+ // requested end state already holds, so this is a SUCCESS naming what did not happen),
1684
+ // and it matches what closeAccount already does with `already-closed`.
1685
+ if (target.closed !== true) {
1686
+ return { outcome: 'already-open', name: target.name };
1687
+ }
1441
1688
  await withConcurrency(() => retry(() => rawReopenAccount(id), { retries: 2, backoffMs: 200 }));
1689
+ const after = await withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 }));
1690
+ const survivor = (Array.isArray(after) ? after : []).find((a) => a?.id === id);
1691
+ if (!survivor) {
1692
+ throw new Error(`Account "${id}" disappeared while being reopened. Check the account state in Actual.`);
1693
+ }
1694
+ if (survivor.closed === true) {
1695
+ throw new Error(`Account "${survivor.name ?? id}" (${id}) is still closed after the reopen. The call ` +
1696
+ 'was accepted but had no effect; check the account state in Actual.');
1697
+ }
1698
+ return { outcome: 'reopened', name: survivor.name };
1442
1699
  });
1443
1700
  }
1444
1701
  export async function getCategoryGroups() {
@@ -1459,12 +1716,31 @@ export async function createCategoryGroup(group) {
1459
1716
  export async function updateCategoryGroup(id, fields) {
1460
1717
  observability.incrementToolCall('actual.category_groups.update').catch(() => { });
1461
1718
  return queueWriteOperation(async () => {
1719
+ // #360: `db.update` does not run a SQL UPDATE. It sends CRDT messages, and the apply
1720
+ // path INSERTs when the row was absent, so an unknown id CREATES a partial row rather
1721
+ // than matching nothing. Refuse first, the way updateTag and updateRule already do.
1722
+ // Called with no argument, upstream returns all groups including hidden ones, so a
1723
+ // hidden group is not misreported as missing (the same reason category_groups_delete
1724
+ // can rely on this listing).
1725
+ const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1726
+ if (!(Array.isArray(groups) && groups.some((g) => g?.id === id))) {
1727
+ throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1728
+ }
1462
1729
  await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1463
1730
  });
1464
1731
  }
1732
+ /**
1733
+ * #376: the existence guard moved here from `src/tools/category_groups_delete.ts`. Same
1734
+ * reasoning as `deleteRule` above: the tool-layer version forwent `retry` on the read and
1735
+ * left this method reachable with no guard at all.
1736
+ */
1465
1737
  export async function deleteCategoryGroup(id) {
1466
1738
  observability.incrementToolCall('actual.category_groups.delete').catch(() => { });
1467
1739
  return queueWriteOperation(async () => {
1740
+ const groups = await withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 }));
1741
+ if (!Array.isArray(groups) || !groups.some((g) => g?.id === id)) {
1742
+ throw new NotFoundRefusal('Category group', id, 'actual_category_groups_get');
1743
+ }
1468
1744
  // Non-idempotent: do not retry (#165).
1469
1745
  await withConcurrency(() => retry(() => rawDeleteCategoryGroup(id), { retries: 0, backoffMs: 200 }));
1470
1746
  });
@@ -1598,16 +1874,38 @@ export async function batchBudgetUpdates(fn) {
1598
1874
  * `undefined` is deliberately NOT treated as a refusal: an older or future build
1599
1875
  * that returns nothing must keep working. Only an explicit `false` is a verdict.
1600
1876
  */
1877
+ /**
1878
+ * #355, at the adapter layer since #371.
1879
+ *
1880
+ * Upstream has TWO ways of doing less than asked, and only one of them is a boolean:
1881
+ *
1882
+ * holdForNextMonth: if (toBudget > 0) { ...; return true; } return false;
1883
+ * calcBufferedAmount: amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
1884
+ *
1885
+ * So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
1886
+ * 100.00 with 30.00 left to budget and 30.00 is held, silently. Reading `forNextMonth`
1887
+ * before and after settles both from observed state, which is the #347 principle and is
1888
+ * strictly better than trusting either return value.
1889
+ *
1890
+ * Returns the amount ACTUALLY held. The caller compares it with what it requested.
1891
+ */
1601
1892
  export async function holdBudgetForNextMonth(month, amount) {
1602
1893
  observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
1603
1894
  return queueWriteOperation(async () => {
1895
+ // `isRetryable` matters here, unlike most read sites: upstream's `api/budget-month` opens
1896
+ // with `validateMonth` and throws for an out-of-range month. Without a classifier,
1897
+ // retry() retries EVERY rejection (see retry.ts), so a deterministic domain error would
1898
+ // cost three upstream calls and 600ms of sleeping inside the api mutex, against the one
1899
+ // withOpTimeout budget this whole callback shares. #177 doctrine: domain errors are
1900
+ // terminal.
1901
+ const before = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1902
+ const heldBefore = Number(before?.forNextMonth ?? 0);
1604
1903
  // Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
1605
1904
  // (`return buffered + amount`), so a second attempt after a committed first does not
1606
- // re-set the buffer, it adds to it. The window is narrow, because the call runs
1607
- // against local SQLite and no `TRANSIENT_ERROR_PATTERNS` message can arise inside
1608
- // it, but the operation is non-idempotent by construction and belongs on the same
1609
- // footing as `deleteRule` and `closeAccount`.
1610
- return await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1905
+ // re-set the buffer, it adds to it.
1906
+ await withConcurrency(() => retry(() => rawHoldBudgetForNextMonth(month, amount), { retries: 0, backoffMs: 200 }));
1907
+ const after = await withConcurrency(() => retry(() => rawGetBudgetMonth(month), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
1908
+ return Number(after?.forNextMonth ?? 0) - heldBefore;
1611
1909
  });
1612
1910
  }
1613
1911
  export async function resetBudgetHold(month) {
@@ -2133,7 +2431,7 @@ export async function updateTag(id, fields) {
2133
2431
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2134
2432
  const exists = tags.some((t) => t.id === id);
2135
2433
  if (!exists) {
2136
- throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
2434
+ throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2137
2435
  }
2138
2436
  await withConcurrency(() => retry(() => rawUpdateTag(id, fields), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2139
2437
  });
@@ -2146,7 +2444,7 @@ export async function deleteTag(id) {
2146
2444
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2147
2445
  const exists = tags.some((t) => t.id === id);
2148
2446
  if (!exists) {
2149
- throw new Error(notFoundMsg('Tag', id, 'actual_tags_list'));
2447
+ throw new NotFoundRefusal('Tag', id, 'actual_tags_list');
2150
2448
  }
2151
2449
  await withConcurrency(() => retry(() => rawDeleteTag(id), { retries: 0, backoffMs: 200 }));
2152
2450
  });
@@ -2157,9 +2455,42 @@ export async function getNote(id) {
2157
2455
  return await withConcurrency(() => retry(() => rawGetNote(id), { retries: 2, backoffMs: 200 }));
2158
2456
  });
2159
2457
  }
2458
+ /** A note id of this shape is a budget MONTH note and resolves to no entity row. */
2459
+ const BUDGET_MONTH_NOTE_RE = /^budget-\d{4}-\d{2}$/;
2460
+ /**
2461
+ * #376: the orphan-id guard moved here from `src/tools/notes_update.ts`.
2462
+ *
2463
+ * WHY THIS ONE MATTERS MOST OF THE FIVE. The tool version issued FOUR `adapter.get*` calls
2464
+ * through `Promise.all`, each opening its own `withActualApi` cycle, and then a fifth cycle
2465
+ * for the write: five api lock acquisitions for one logical operation. `Promise.all` made it
2466
+ * look concurrent, but the api mutex is process-global, so they serialised anyway. Reading
2467
+ * inside one `queueWriteOperation` makes it ONE cycle, and the guard now sees the same
2468
+ * snapshot the write lands on.
2469
+ *
2470
+ * The refusal is a NotFoundRefusal so the tool can recognise it by type (#377). The tool
2471
+ * converts it to its published `{ error }` shape, which is a known deviation from the
2472
+ * refusal taxonomy and is tracked on #377 rather than changed here.
2473
+ */
2160
2474
  export async function updateNote(id, note) {
2161
2475
  observability.incrementToolCall('actual.notes.update').catch(() => { });
2162
2476
  return queueWriteOperation(async () => {
2477
+ // A budget-YYYY-MM id is synthetic: it names a month, not a row, so there is nothing
2478
+ // to look up and the four reads below would all miss.
2479
+ if (!BUDGET_MONTH_NOTE_RE.test(id)) {
2480
+ const has = (rows) => Array.isArray(rows) && rows.some((e) => e?.id === id);
2481
+ const [accounts, categories, categoryGroups, payees] = await Promise.all([
2482
+ withConcurrency(() => retry(() => rawGetAccounts(), { retries: 2, backoffMs: 200 })),
2483
+ withConcurrency(() => retry(() => rawGetCategories(), { retries: 2, backoffMs: 200 })),
2484
+ withConcurrency(() => retry(() => rawGetCategoryGroups(), { retries: 2, backoffMs: 200 })),
2485
+ withConcurrency(() => retry(() => rawGetPayees(), { retries: 2, backoffMs: 200 })),
2486
+ ]);
2487
+ if (!has(accounts) && !has(categories) && !has(categoryGroups) && !has(payees)) {
2488
+ // Guarding matters here for the same reason as #360: upstream's note write is a
2489
+ // CRDT message, and the apply path INSERTs when the row is absent, so an unknown
2490
+ // id creates an orphan note that no tool can ever read back.
2491
+ throw new NotFoundRefusal('Entity', id, 'actual_accounts_list, actual_categories_get, actual_category_groups_get or actual_payees_get', 'A budget month note uses an id like "budget-2026-01".');
2492
+ }
2493
+ }
2163
2494
  await withConcurrency(() => retry(() => rawUpdateNote(id, note), { retries: 0, backoffMs: 200, isRetryable: isRetryableError }));
2164
2495
  });
2165
2496
  }
@@ -2285,6 +2616,7 @@ export default {
2285
2616
  createRule,
2286
2617
  updateRule,
2287
2618
  deleteRule,
2619
+ upsertRule,
2288
2620
  setBudgetCarryover,
2289
2621
  closeAccount,
2290
2622
  reopenAccount,