actual-mcp-server 0.12.0 → 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 +5 -5
- package/dist/src/lib/actual-adapter.js +211 -14
- package/dist/src/tools/accounts_close.js +17 -118
- package/dist/src/tools/accounts_reopen.js +8 -81
- package/dist/src/tools/budgets_holdForNextMonth.js +24 -57
- package/dist/src/tools/budgets_setAmount.js +26 -6
- package/package.json +2 -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.13.0 | **Tool Count:** 74 (verified LibreChat-compatible)
|
|
778
778
|
|
|
779
779
|
## Sponsor
|
|
780
780
|
|
|
@@ -999,6 +999,21 @@ export async function setBudgetAmount(month, categoryId, amount) {
|
|
|
999
999
|
if (!exists) {
|
|
1000
1000
|
throw new Error(`Category "${categoryId}" not found. Use actual_categories_get to list available categories.`);
|
|
1001
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
|
+
}
|
|
1002
1017
|
const result = await withConcurrency(() => retry(() => rawSetBudgetAmount(month, categoryId, amount), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1003
1018
|
return result;
|
|
1004
1019
|
});
|
|
@@ -1052,6 +1067,15 @@ export async function createAccount(account, initialBalance) {
|
|
|
1052
1067
|
export async function updateAccount(id, fields) {
|
|
1053
1068
|
observability.incrementToolCall('actual.accounts.update').catch(() => { });
|
|
1054
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
|
+
}
|
|
1055
1079
|
await withConcurrency(() => retry(() => rawUpdateAccount(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1056
1080
|
return null;
|
|
1057
1081
|
});
|
|
@@ -1213,6 +1237,15 @@ export async function deleteTransaction(id) {
|
|
|
1213
1237
|
export async function updateCategory(id, fields) {
|
|
1214
1238
|
observability.incrementToolCall('actual.categories.update').catch(() => { });
|
|
1215
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
|
+
}
|
|
1216
1249
|
await withConcurrency(() => retry(() => rawUpdateCategory(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1217
1250
|
});
|
|
1218
1251
|
}
|
|
@@ -1231,6 +1264,20 @@ export async function deleteCategory(id) {
|
|
|
1231
1264
|
export async function updatePayee(id, fields) {
|
|
1232
1265
|
observability.incrementToolCall('actual.payees.update').catch(() => { });
|
|
1233
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
|
+
}
|
|
1234
1281
|
const fieldsObj = fields;
|
|
1235
1282
|
// Extract `category` — it is NOT a direct column on the payees table in Actual Budget.
|
|
1236
1283
|
// The "default category" feature is implemented via rules (condition: payee is X → action: set category).
|
|
@@ -1418,27 +1465,145 @@ export async function setBudgetCarryover(month, categoryId, flag) {
|
|
|
1418
1465
|
});
|
|
1419
1466
|
}
|
|
1420
1467
|
/**
|
|
1421
|
-
* #357
|
|
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:
|
|
1422
1472
|
*
|
|
1423
|
-
* `closeAccount(
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
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.
|
|
1428
1479
|
*
|
|
1429
|
-
*
|
|
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.
|
|
1430
1490
|
*/
|
|
1431
1491
|
export async function closeAccount(id, transferAccountId, transferCategoryId) {
|
|
1432
1492
|
observability.incrementToolCall('actual.accounts.close').catch(() => { });
|
|
1433
1493
|
return queueWriteOperation(async () => {
|
|
1434
|
-
|
|
1435
|
-
|
|
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 };
|
|
1436
1556
|
});
|
|
1437
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
|
+
*/
|
|
1438
1586
|
export async function reopenAccount(id) {
|
|
1439
1587
|
observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
|
|
1440
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
|
+
}
|
|
1441
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
|
+
}
|
|
1442
1607
|
});
|
|
1443
1608
|
}
|
|
1444
1609
|
export async function getCategoryGroups() {
|
|
@@ -1459,6 +1624,16 @@ export async function createCategoryGroup(group) {
|
|
|
1459
1624
|
export async function updateCategoryGroup(id, fields) {
|
|
1460
1625
|
observability.incrementToolCall('actual.category_groups.update').catch(() => { });
|
|
1461
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
|
+
}
|
|
1462
1637
|
await withConcurrency(() => retry(() => rawUpdateCategoryGroup(id, fields), { retries: 2, backoffMs: 200, isRetryable: isRetryableError }));
|
|
1463
1638
|
});
|
|
1464
1639
|
}
|
|
@@ -1598,16 +1773,38 @@ export async function batchBudgetUpdates(fn) {
|
|
|
1598
1773
|
* `undefined` is deliberately NOT treated as a refusal: an older or future build
|
|
1599
1774
|
* that returns nothing must keep working. Only an explicit `false` is a verdict.
|
|
1600
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
|
+
*/
|
|
1601
1791
|
export async function holdBudgetForNextMonth(month, amount) {
|
|
1602
1792
|
observability.incrementToolCall('actual.budgets.holdForNextMonth').catch(() => { });
|
|
1603
1793
|
return queueWriteOperation(async () => {
|
|
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);
|
|
1604
1802
|
// Non-idempotent: do not retry (#165). `calcBufferedAmount` is ADDITIVE
|
|
1605
1803
|
// (`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 }));
|
|
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;
|
|
1611
1808
|
});
|
|
1612
1809
|
}
|
|
1613
1810
|
export async function resetBudgetHold(month) {
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import * as observability from '../observability.js';
|
|
4
|
-
import api from '@actual-app/api';
|
|
5
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
-
const { getAccounts: rawGetAccounts, getCategories: rawGetCategories, closeAccount: rawCloseAccount } = api;
|
|
7
3
|
const InputSchema = z
|
|
8
4
|
.object({
|
|
9
5
|
id: z.string().min(1).max(64).describe('Account ID to close'),
|
|
@@ -33,40 +29,13 @@ const InputSchema = z
|
|
|
33
29
|
}
|
|
34
30
|
});
|
|
35
31
|
/**
|
|
36
|
-
* #357
|
|
37
|
-
*
|
|
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.
|
|
38
36
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* or an already-closed account did nothing and reported success. Same upstream
|
|
42
|
-
* early-return that produced #347.
|
|
43
|
-
*
|
|
44
|
-
* (b) CLOSE CAN DELETE. `if (numTransactions === 0) { await db.deleteAccount({id}); }`.
|
|
45
|
-
* An account with no transactions is TOMBSTONED rather than closed, it disappears
|
|
46
|
-
* from every listing, and `actual_accounts_reopen` cannot bring it back. The old
|
|
47
|
-
* description promised "their transaction history is preserved", which is false in
|
|
48
|
-
* exactly this case. The repo already knew: tests/manual/tests/account.js adds a
|
|
49
|
-
* dummy transaction with a comment explaining it.
|
|
50
|
-
*
|
|
51
|
-
* (c) A BALANCE MADE IT UNCLOSABLE. `transferAccountId` and `transferCategoryId` are
|
|
52
|
-
* documented parameters, the schema did not accept them, and upstream throws
|
|
53
|
-
* `balance is non-zero: transferAccountId is required` without them. The caller
|
|
54
|
-
* was told to supply something no tool would take.
|
|
55
|
-
*
|
|
56
|
-
* The read, the write and the verification run inside ONE `withWriteSession` cycle
|
|
57
|
-
* (#142). A read-BEFORE is required here, unlike #347's pure verify-after, because (b)
|
|
58
|
-
* makes "absent afterwards" ambiguous: an account deleted by the close and an id that
|
|
59
|
-
* never existed look identical from a single post-read.
|
|
60
|
-
*
|
|
61
|
-
* Going through the raw api inside the session means this path no longer gets the
|
|
62
|
-
* adapter's `retry`. That is inherent to the #142 pattern, shared with every tool using
|
|
63
|
-
* it, and it is the half of #368 that still stands. The per-tool counter is NOT lost:
|
|
64
|
-
* it is incremented in the handler below.
|
|
65
|
-
*
|
|
66
|
-
* Already-closed is reported as success rather than as an error, deliberately. The
|
|
67
|
-
* caller's intent ("this account should be closed") is already satisfied, which is the
|
|
68
|
-
* same idempotence argument #347 used for delete. What changes is that the response no
|
|
69
|
-
* longer CLAIMS a state change that did not happen.
|
|
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.
|
|
70
39
|
*/
|
|
71
40
|
const tool = {
|
|
72
41
|
name: 'actual_accounts_close',
|
|
@@ -78,95 +47,25 @@ const tool = {
|
|
|
78
47
|
inputSchema: InputSchema,
|
|
79
48
|
call: async (args, _meta) => {
|
|
80
49
|
const input = InputSchema.parse(args || {});
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return await adapter.withWriteSession(async () => {
|
|
86
|
-
const before = (await rawGetAccounts());
|
|
87
|
-
const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
|
|
88
|
-
if (!target) {
|
|
89
|
-
throw new Error(`Account "${input.id}" not found. Use actual_accounts_list to see the accounts that ` +
|
|
90
|
-
'exist. Note that an account closed while it had no transactions was REMOVED by ' +
|
|
91
|
-
'Actual, not closed, so it will not appear there.');
|
|
92
|
-
}
|
|
93
|
-
if (target.closed === true) {
|
|
94
|
-
// Idempotent and truthful: the requested state already holds, and the response
|
|
95
|
-
// says so instead of implying this call changed anything.
|
|
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':
|
|
96
54
|
return {
|
|
97
55
|
success: true,
|
|
98
56
|
alreadyClosed: true,
|
|
99
|
-
message: `Account "${
|
|
57
|
+
message: `Account "${name}" was already closed. Nothing changed.`,
|
|
100
58
|
};
|
|
101
|
-
|
|
102
|
-
if (input.transferAccountId) {
|
|
103
|
-
const destination = Array.isArray(before)
|
|
104
|
-
? before.find((a) => a?.id === input.transferAccountId)
|
|
105
|
-
: undefined;
|
|
106
|
-
if (!destination) {
|
|
107
|
-
throw new Error(`Transfer destination account "${input.transferAccountId}" not found. Use ` +
|
|
108
|
-
'actual_accounts_list to pick an account to move the remaining balance to.');
|
|
109
|
-
}
|
|
110
|
-
if (destination.closed === true) {
|
|
111
|
-
// The balancing transaction would land in a closed account, where it is hidden
|
|
112
|
-
// from most views. Upstream does not stop this; refusing is the kinder answer.
|
|
113
|
-
throw new Error(`Transfer destination account "${destination.name ?? input.transferAccountId}" is ` +
|
|
114
|
-
'CLOSED, so the closing balance would be moved somewhere hidden from most views. ' +
|
|
115
|
-
'Pick an open account, or reopen that one with actual_accounts_reopen first.');
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (input.transferCategoryId) {
|
|
119
|
-
// #359's lesson, applied to the only NEW write this change adds. Upstream forwards
|
|
120
|
-
// this id straight into `transaction-add` without checking it (api.ts's
|
|
121
|
-
// account-close passes it as `categoryId`, and addTransaction is a bare batch
|
|
122
|
-
// update), so a bogus value writes a "Closing account" transaction carrying a
|
|
123
|
-
// category that does not exist, and syncs it to every client. Same dangling
|
|
124
|
-
// reference the transactions_create guard exists to prevent.
|
|
125
|
-
const categories = (await rawGetCategories());
|
|
126
|
-
const categoryExists = Array.isArray(categories)
|
|
127
|
-
? categories.some((c) => c?.id === input.transferCategoryId)
|
|
128
|
-
: false;
|
|
129
|
-
if (!categoryExists) {
|
|
130
|
-
throw new Error(`Transfer category "${input.transferCategoryId}" not found. Use ` +
|
|
131
|
-
'actual_categories_get to pick a category for the closing transaction, or omit ' +
|
|
132
|
-
'transferCategoryId to leave it uncategorised.');
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
await rawCloseAccount(input.id, input.transferAccountId, input.transferCategoryId);
|
|
137
|
-
}
|
|
138
|
-
catch (err) {
|
|
139
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
-
if (/balance is non-zero/i.test(msg)) {
|
|
141
|
-
// Translate upstream's message into one the caller can act on, now that the
|
|
142
|
-
// parameter it asks for actually exists on this tool.
|
|
143
|
-
throw new Error(`Account "${target.name ?? input.id}" has a non-zero balance, so closing it requires ` +
|
|
144
|
-
'transferAccountId: the id of the account to move the remaining balance to. ' +
|
|
145
|
-
'Optionally pass transferCategoryId to categorise the balancing transaction. ' +
|
|
146
|
-
'Use actual_accounts_list to pick a destination.');
|
|
147
|
-
}
|
|
148
|
-
throw err;
|
|
149
|
-
}
|
|
150
|
-
// Verify the effect rather than the call returning (#347).
|
|
151
|
-
const after = (await rawGetAccounts());
|
|
152
|
-
const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
|
|
153
|
-
if (!survivor) {
|
|
154
|
-
// Defect (b): upstream tombstones a zero-transaction account instead of closing
|
|
155
|
-
// it. Saying so is the whole point; the old description claimed the opposite.
|
|
59
|
+
case 'removed':
|
|
156
60
|
return {
|
|
157
61
|
success: true,
|
|
158
62
|
removed: true,
|
|
159
|
-
message: `Account "${
|
|
160
|
-
'
|
|
161
|
-
'reopened.',
|
|
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.',
|
|
162
65
|
};
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
'The call was accepted but had no effect; check the account state in Actual.');
|
|
167
|
-
}
|
|
168
|
-
return { success: true, closed: true };
|
|
169
|
-
});
|
|
66
|
+
default:
|
|
67
|
+
return { success: true, closed: true };
|
|
68
|
+
}
|
|
170
69
|
},
|
|
171
70
|
};
|
|
172
71
|
export default tool;
|
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import * as observability from '../observability.js';
|
|
4
|
-
import api from '@actual-app/api';
|
|
5
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
-
const { getAccounts: rawGetAccounts, reopenAccount: rawReopenAccount } = api;
|
|
7
3
|
const InputSchema = z.object({
|
|
8
4
|
// Bounded, because the id is echoed into the error message and into the structured
|
|
9
5
|
// logs. Not tightened to CommonSchemas.accountId (a UUID) here: several fixtures use
|
|
@@ -11,58 +7,13 @@ const InputSchema = z.object({
|
|
|
11
7
|
id: z.string().min(1).max(64).describe('Account ID to reopen'),
|
|
12
8
|
});
|
|
13
9
|
/**
|
|
14
|
-
* #358
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
13
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* `db.update` does not run a SQL UPDATE. It sends CRDT messages, and `apply()` in
|
|
23
|
-
* loot-core/src/server/sync/index.ts picks INSERT when the row was absent from the
|
|
24
|
-
* pre-read:
|
|
25
|
-
*
|
|
26
|
-
* INSERT INTO accounts (id, closed) VALUES (?, ?)
|
|
27
|
-
*
|
|
28
|
-
* The accounts table has no NOT NULL columns and `tombstone` defaults to 0, while
|
|
29
|
-
* `getAccounts()` selects `WHERE a.tombstone = 0`. So the phantom row is a fully
|
|
30
|
-
* visible account with a null name, and because it is a CRDT message it SYNCS to
|
|
31
|
-
* every other client on the budget.
|
|
32
|
-
*
|
|
33
|
-
* WHY A PRE-CHECK HERE, WHEN #347 DELIBERATELY REJECTED ONE.
|
|
34
|
-
*
|
|
35
|
-
* The two cases look identical and are not. In #347 the caller's intent ("make this
|
|
36
|
-
* account not exist") could ALREADY be satisfied by an absent row, so refusing on
|
|
37
|
-
* absence would have failed a request that was in fact complete. Here the caller's
|
|
38
|
-
* intent ("reopen this account") cannot possibly be satisfied by an absent row, and
|
|
39
|
-
* proceeding actively creates one. Refusing is the only correct answer.
|
|
40
|
-
*
|
|
41
|
-
* The post-verify is kept as well, so the success claim is about observed state
|
|
42
|
-
* rather than about the call having returned. Read, write and re-read all run inside
|
|
43
|
-
* ONE `withWriteSession` cycle (#142), which keeps them in a single api lock cycle
|
|
44
|
-
* rather than three.
|
|
45
|
-
*
|
|
46
|
-
* What that does NOT give, stated precisely because an overstated guarantee gets relied
|
|
47
|
-
* on: `processWriteQueue` dispatches a batch with `Promise.allSettled`, so operations
|
|
48
|
-
* queued in the same drain window still interleave at await points. The single cycle
|
|
49
|
-
* excludes other SESSIONS, not other operations in the same batch. A concurrent
|
|
50
|
-
* close of the same account can therefore still land between this read and this write.
|
|
51
|
-
*
|
|
52
|
-
* TWO DELIBERATE LIMITS, recorded so they are not mistaken for oversights.
|
|
53
|
-
*
|
|
54
|
-
* The not-found message covers both reasons an id can be missing (never existed, or
|
|
55
|
-
* removed by a close while it had no transactions) rather than distinguishing them.
|
|
56
|
-
* `q().withDead()` would tell them apart by reading tombstoned rows, at the cost of an
|
|
57
|
-
* extra query on the failure path. Collapsing them was chosen because the message names
|
|
58
|
-
* both cases and the remedy is the same either way. If that proves confusing in
|
|
59
|
-
* practice, `withDead()` is the tool to reach for.
|
|
60
|
-
*
|
|
61
|
-
* Going through the raw api inside the session means this path no longer gets the
|
|
62
|
-
* adapter's `retry`. That is inherent to the #142 pattern, shared with every other tool
|
|
63
|
-
* using it (`rules_delete`, `schedules_delete`, `category_groups_delete`), and it is the
|
|
64
|
-
* half of #368 that still stands. The per-tool counter is NOT lost: it is incremented in
|
|
65
|
-
* the handler below.
|
|
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.
|
|
66
17
|
*/
|
|
67
18
|
const tool = {
|
|
68
19
|
name: 'actual_accounts_reopen',
|
|
@@ -74,32 +25,8 @@ const tool = {
|
|
|
74
25
|
inputSchema: InputSchema,
|
|
75
26
|
call: async (args, _meta) => {
|
|
76
27
|
const input = InputSchema.parse(args || {});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
// here keeps `actual.accounts.reopen` honest; the retry half of #368 still stands.
|
|
80
|
-
observability.incrementToolCall('actual.accounts.reopen').catch(() => { });
|
|
81
|
-
return await adapter.withWriteSession(async () => {
|
|
82
|
-
const before = (await rawGetAccounts());
|
|
83
|
-
const target = Array.isArray(before) ? before.find((a) => a?.id === input.id) : undefined;
|
|
84
|
-
if (!target) {
|
|
85
|
-
throw new Error(`Account "${input.id}" not found, so it cannot be reopened. Use actual_accounts_list to ` +
|
|
86
|
-
'see the accounts that exist. If this account was closed while it had no transactions, ' +
|
|
87
|
-
'Actual removed it rather than closing it, and it cannot be reopened: create a new one ' +
|
|
88
|
-
'with actual_accounts_create.');
|
|
89
|
-
}
|
|
90
|
-
await rawReopenAccount(input.id);
|
|
91
|
-
// Verify the effect rather than the call returning. Same reasoning as #347.
|
|
92
|
-
const after = (await rawGetAccounts());
|
|
93
|
-
const survivor = Array.isArray(after) ? after.find((a) => a?.id === input.id) : undefined;
|
|
94
|
-
if (!survivor) {
|
|
95
|
-
throw new Error(`Account "${input.id}" disappeared while being reopened. Check the account state in Actual.`);
|
|
96
|
-
}
|
|
97
|
-
if (survivor.closed === true) {
|
|
98
|
-
throw new Error(`Account "${survivor.name ?? input.id}" (${input.id}) is still closed after the reopen. ` +
|
|
99
|
-
'The call was accepted but had no effect; check the account state in Actual.');
|
|
100
|
-
}
|
|
101
|
-
return { success: true };
|
|
102
|
-
});
|
|
28
|
+
await adapter.reopenAccount(input.id);
|
|
29
|
+
return { success: true };
|
|
103
30
|
},
|
|
104
31
|
};
|
|
105
32
|
export default tool;
|
|
@@ -1,44 +1,22 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import adapter from '../lib/actual-adapter.js';
|
|
3
|
-
import * as observability from '../observability.js';
|
|
4
|
-
import api from '@actual-app/api';
|
|
5
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
6
|
-
const { getBudgetMonth: rawGetBudgetMonth, holdBudgetForNextMonth: rawHold } = api;
|
|
7
3
|
const InputSchema = z.object({
|
|
8
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'),
|
|
9
5
|
// #355: upstream rejects a non-positive amount with
|
|
10
6
|
// APIError('Amount to hold needs to be greater than 0') BEFORE it ever reaches
|
|
11
7
|
// holdForNextMonth, so the schema rejects it here instead: same answer, no wasted
|
|
12
|
-
// round trip, and the message names the field.
|
|
13
|
-
// value upstream already refuses.
|
|
8
|
+
// round trip, and the message names the field.
|
|
14
9
|
amount: z.number().int().positive('amount must be greater than 0 (integer cents)')
|
|
15
10
|
.describe('Amount in cents to hold for next month. Must be greater than 0.'),
|
|
16
11
|
});
|
|
17
12
|
/**
|
|
18
|
-
* #355
|
|
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.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* upstream holdForNextMonth:
|
|
24
|
-
* if (toBudget > 0) { await setBuffer(month, calcBufferedAmount(...)); return true; }
|
|
25
|
-
* return false;
|
|
26
|
-
*
|
|
27
|
-
* calcBufferedAmount:
|
|
28
|
-
* amount = Math.min(Math.max(amount, -buffered), Math.max(toBudget, 0));
|
|
29
|
-
*
|
|
30
|
-
* So `false` means nothing was held, and `true` can still mean a PARTIAL hold: ask for
|
|
31
|
-
* 100.00 when only 30.00 is left to budget and 30.00 is held, silently, with a `true`.
|
|
32
|
-
* Reporting plain success for that is the same defect in a different costume.
|
|
33
|
-
*
|
|
34
|
-
* Reading `forNextMonth` (the buffered amount) before and after settles both cases from
|
|
35
|
-
* observed state rather than from a return value, which is the #347 principle. Both
|
|
36
|
-
* reads and the write share ONE `withWriteSession` cycle (#142).
|
|
37
|
-
*
|
|
38
|
-
* Cost and trade-off, recorded: two extra reads on a low-frequency budget tool, and the
|
|
39
|
-
* raw-call path forgoes the adapter's retry, which is inherent to the #142 pattern and is
|
|
40
|
-
* the half of #368 that still stands. The per-tool counter is NOT lost: it is incremented
|
|
41
|
-
* in the handler below.
|
|
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.
|
|
42
20
|
*/
|
|
43
21
|
const tool = {
|
|
44
22
|
name: 'actual_budgets_holdForNextMonth',
|
|
@@ -50,34 +28,23 @@ const tool = {
|
|
|
50
28
|
inputSchema: InputSchema,
|
|
51
29
|
call: async (args, _meta) => {
|
|
52
30
|
const input = InputSchema.parse(args || {});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
success: true,
|
|
72
|
-
partial: true,
|
|
73
|
-
requested: input.amount,
|
|
74
|
-
held,
|
|
75
|
-
message: `Held ${held} of the requested ${input.amount} cents for ${input.month}. Actual clamps ` +
|
|
76
|
-
'the hold to the amount still left to budget in that month.',
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
return { success: true, held };
|
|
80
|
-
});
|
|
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 };
|
|
81
48
|
},
|
|
82
49
|
};
|
|
83
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}`);
|
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/write_effect_audit_staleness.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/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",
|
|
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",
|