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 +5 -5
- package/dist/src/lib/actual-adapter.js +356 -24
- package/dist/src/lib/errors.js +104 -2
- package/dist/src/lib/rejection-allowlist.js +9 -1
- package/dist/src/lib/rule-matching.js +62 -0
- package/dist/src/tools/accounts_close.js +21 -119
- package/dist/src/tools/accounts_reopen.js +24 -82
- package/dist/src/tools/budgets_holdForNextMonth.js +29 -58
- package/dist/src/tools/budgets_setAmount.js +26 -6
- package/dist/src/tools/category_groups_delete.js +7 -18
- package/dist/src/tools/notes_update.js +18 -23
- package/dist/src/tools/payees_delete.js +7 -2
- package/dist/src/tools/payees_merge.js +12 -8
- package/dist/src/tools/rules_create_or_update.js +27 -93
- package/dist/src/tools/rules_delete.js +14 -35
- package/dist/src/tools/schedules_delete.js +7 -30
- package/dist/src/tools/transactions_create.js +6 -2
- package/package.json +3 -2
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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
|
|
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
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1435
|
-
|
|
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.
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
|
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
|
|
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,
|